2026-09-15 22:51:33 +02:00
using System.Globalization ;
using Microsoft.Data.Sqlite ;
using VoiceCat.Crypto ;
namespace VoiceCat.Server.Data ;
public sealed record Account ( long Id , string Username , bool IsAdmin , long CreatedAt , long LastLogin );
2026-09-15 23:11:09 +02:00
public sealed partial class AccountStore : IDisposable
2026-09-15 22:51:33 +02:00
{
static AccountStore () => SQLitePCL . Batteries_V2 . Init ();
private readonly string connectionString ;
private readonly PasswordHasher hasher = new ();
private readonly SemaphoreSlim passwordWorkers = new ( 2 );
private bool disposed ;
private const string DummyHash = "$argon2id$v=19$m=65536,t=2,p=1$AAECAwQFBgcICQoLDA0ODw$Ki9tdSYqOtze3s3LAS6gv6I0buTIh2abdjWzY3GeLiE" ;
public AccountStore ( string path )
{
connectionString = new SqliteConnectionStringBuilder { DataSource = Path . GetFullPath ( path ), Pooling = false , DefaultTimeout = 5 }. ToString ();
using var connection = Open ();
using var setup = connection . CreateCommand ();
setup . CommandText = "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);" ;
setup . ExecuteNonQuery ();
using var transaction = connection . BeginTransaction ();
using var version = connection . CreateCommand ();
version . Transaction = transaction ;
version . CommandText = "SELECT value FROM server_meta WHERE key='schema_version'" ;
object? stored = version . ExecuteScalar ();
if ( stored is not null && (! int . TryParse (( string ) stored , NumberStyles . None , CultureInfo . InvariantCulture , out int revision ) || revision is < 1 or > 2 ))
throw new InvalidDataException ( "Unsupported server database schema version." );
using var resource = typeof ( AccountStore ). Assembly . GetManifestResourceStream ( "VoiceCat.Server.Data.schema.sql" )!;
using var reader = new StreamReader ( resource );
using var migrate = connection . CreateCommand ();
migrate . Transaction = transaction ;
migrate . CommandText = reader . ReadToEnd () + "INSERT INTO server_meta (key,value) VALUES ('schema_version','2') ON CONFLICT(key) DO UPDATE SET value='2';" ;
migrate . ExecuteNonQuery ();
transaction . Commit ();
}
private SqliteConnection Open ()
{
ObjectDisposedException . ThrowIf ( disposed , this );
var connection = new SqliteConnection ( connectionString );
try { connection . Open (); return connection ; }
catch { connection . Dispose (); throw ; }
}
public async Task < Account > CreateAccountAsync ( string username , string password , bool isAdmin = false , CancellationToken cancellationToken = default )
{
ArgumentException . ThrowIfNullOrEmpty ( username );
if ( username . Length > 128 ) throw new ArgumentException ( "Username exceeds 128 characters." , nameof ( username ));
string hash = await PasswordWorkAsync (() => hasher . Hash ( password ), cancellationToken ). ConfigureAwait ( false );
cancellationToken . ThrowIfCancellationRequested ();
using var connection = Open ();
using var command = connection . CreateCommand ();
long created = DateTimeOffset . UtcNow . ToUnixTimeSeconds ();
command . CommandText = "INSERT INTO accounts (username,pw_hash,is_admin,created_at) VALUES ($user,$hash,$admin,$created) RETURNING id" ;
command . Parameters . AddWithValue ( "$user" , username );
command . Parameters . AddWithValue ( "$hash" , hash );
command . Parameters . AddWithValue ( "$admin" , isAdmin ? 1 : 0 );
command . Parameters . AddWithValue ( "$created" , created );
return new (( long ) command . ExecuteScalar ()!, username , isAdmin , created , 0 );
}
public async Task < Account ?> AuthenticateAsync ( string username , string password , CancellationToken cancellationToken = default )
{
string? hash = null ;
Account ? account = null ;
using ( var connection = Open ())
using ( var command = connection . CreateCommand ())
{
command . CommandText = "SELECT id,pw_hash,is_admin,created_at,last_login FROM accounts WHERE username=$user" ;
command . Parameters . AddWithValue ( "$user" , username );
using var reader = command . ExecuteReader ();
if ( reader . Read ())
{
hash = reader . GetString ( 1 );
account = new ( reader . GetInt64 ( 0 ), username , reader . GetInt64 ( 2 ) != 0 , reader . GetInt64 ( 3 ), reader . GetInt64 ( 4 ));
}
}
bool verified = await PasswordWorkAsync (() => hasher . Verify ( password , hash ?? DummyHash ), cancellationToken ). ConfigureAwait ( false );
if ( hash is null || ! verified ) return null ;
cancellationToken . ThrowIfCancellationRequested ();
using var updated = Open ();
using var update = updated . CreateCommand ();
long login = DateTimeOffset . UtcNow . ToUnixTimeSeconds ();
update . CommandText = "UPDATE accounts SET last_login=$login WHERE id=$id AND pw_hash=$hash" ;
update . Parameters . AddWithValue ( "$login" , login );
update . Parameters . AddWithValue ( "$id" , account !. Id );
update . Parameters . AddWithValue ( "$hash" , hash );
return update . ExecuteNonQuery () == 1 ? account with { LastLogin = login } : null ;
}
private async Task < T > PasswordWorkAsync < T >( Func < T > work , CancellationToken cancellationToken )
{
ObjectDisposedException . ThrowIf ( disposed , this );
await passwordWorkers . WaitAsync ( cancellationToken ). ConfigureAwait ( false );
try { return await Task . Run ( work , cancellationToken ). ConfigureAwait ( false ); }
finally { passwordWorkers . Release (); }
}
public void Dispose () => disposed = true ;
public IReadOnlyList < Voicecat . V1 . Channel > LoadChannels ()
{
using var connection = Open ();
using var transaction = connection . BeginTransaction ();
using var seed = connection . CreateCommand ();
seed . Transaction = transaction ;
seed . CommandText = "SELECT COUNT(*) FROM channels" ;
bool empty = ( long ) seed . ExecuteScalar ()! == 0 ;
seed . CommandText = """
INSERT INTO channels ( id , name , max_users ) VALUES ( 1 , ' Lobby ' , 20 );
INSERT INTO channels ( id , name , audio_mode , audio_bitrate_bps , audio_application , audio_fec , audio_expected_packet_loss , audio_dtx , audio_complexity , sort_order )
VALUES ( 2 , ' Music Room ' , 1 , 128000 , 1 , 0 , 0 , 0 , 8 , 1 );
""";
if ( empty ) seed . ExecuteNonQuery ();
transaction . Commit ();
using var command = connection . CreateCommand ();
command . CommandText = """
SELECT id , parent_id , name , topic , password_hash , max_users , type , sort_order ,
audio_codec , audio_mode , audio_sample_rate , audio_bitrate_bps , audio_frame_ms ,
audio_application , audio_fec , audio_expected_packet_loss , audio_dtx , audio_complexity
FROM channels ORDER BY sort_order , id
""";
using var reader = command . ExecuteReader ();
var channels = new List < Voicecat . V1 . Channel >();
while ( reader . Read ())
{
channels . Add ( new ()
{
Id = checked (( uint ) reader . GetInt64 ( 0 )), ParentId = checked (( uint ) reader . GetInt64 ( 1 )),
Name = reader . GetString ( 2 ), Topic = reader . GetString ( 3 ), PasswordProtected = reader . GetString ( 4 ). Length != 0 ,
MaxUsers = checked (( uint ) reader . GetInt64 ( 5 )), Type = ( Voicecat . V1 . ChannelType ) reader . GetInt32 ( 6 ), Order = reader . GetInt32 ( 7 ),
Audio = new ()
{
Codec = checked (( uint ) reader . GetInt64 ( 8 )), Mode = ( Voicecat . V1 . ChannelMode ) reader . GetInt32 ( 9 ),
SampleRate = checked (( uint ) reader . GetInt64 ( 10 )), BitrateBps = checked (( uint ) reader . GetInt64 ( 11 )),
FrameMs = checked (( uint ) reader . GetInt64 ( 12 )), Application = ( Voicecat . V1 . OpusApplication ) reader . GetInt32 ( 13 ),
Fec = reader . GetInt32 ( 14 ) != 0 , ExpectedPacketLoss = checked (( uint ) reader . GetInt64 ( 15 )),
Dtx = reader . GetInt32 ( 16 ) != 0 , Complexity = checked (( uint ) reader . GetInt64 ( 17 ))
}
});
}
return channels ;
}
public bool IsBanned ( string subjectType , string subject )
{
using var connection = Open ();
using var command = connection . CreateCommand ();
command . CommandText = "SELECT 1 FROM bans WHERE subject_type=$type AND subject=$subject AND (expires_at=0 OR expires_at>$now) LIMIT 1" ;
command . Parameters . AddWithValue ( "$type" , subjectType );
command . Parameters . AddWithValue ( "$subject" , subject );
command . Parameters . AddWithValue ( "$now" , DateTimeOffset . UtcNow . ToUnixTimeSeconds ());
return command . ExecuteScalar () is not null ;
}
}