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); public sealed partial class AccountStore : IDisposable { 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 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 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 PasswordWorkAsync(Func 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 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(); 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; } }