Add managed codec DSP and initial control server
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user