Add managed codec DSP and initial control server

This commit is contained in:
2026-09-15 22:51:33 +02:00
parent 2df79cdd4c
commit 4067bab7c2
52 changed files with 2503 additions and 20 deletions
@@ -0,0 +1,159 @@
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 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<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;
}
}
@@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
pw_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_login INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER NOT NULL DEFAULT 0,
name TEXT UNIQUE NOT NULL,
topic TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL DEFAULT '',
max_users INTEGER NOT NULL DEFAULT 0,
type INTEGER NOT NULL DEFAULT 0,
audio_codec INTEGER NOT NULL DEFAULT 0,
audio_mode INTEGER NOT NULL DEFAULT 0,
audio_sample_rate INTEGER NOT NULL DEFAULT 48000,
audio_bitrate_bps INTEGER NOT NULL DEFAULT 24000,
audio_frame_ms INTEGER NOT NULL DEFAULT 20,
audio_application INTEGER NOT NULL DEFAULT 0,
audio_fec INTEGER NOT NULL DEFAULT 1,
audio_expected_packet_loss INTEGER NOT NULL DEFAULT 10,
audio_dtx INTEGER NOT NULL DEFAULT 1,
audio_complexity INTEGER NOT NULL DEFAULT 5,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS bans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject_type TEXT NOT NULL,
subject TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
expires_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_bans_subject ON bans(subject_type, subject);