feat(api): bind encrypted workspaces to accounts and devices
This commit is contained in:
1 parent
019b15041a
commit
c7d8ef15bc
49 files changed
+1904
-278
No files matched your search
@@ -0,0 +1,236 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace WebDrop.Storage;
|
||||
|
||||
/// <summary>Account authentication and opaque client-encrypted vault storage.</summary>
|
||||
public sealed class SqliteAccountStore : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly object _gate = new();
|
||||
|
||||
public SqliteAccountStore(string path)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
|
||||
_connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = true }.ToString());
|
||||
_connection.Open();
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS accounts (username TEXT PRIMARY KEY COLLATE NOCASE, salt BLOB NOT NULL, verifier BLOB NOT NULL, verifier_salt BLOB NOT NULL, public_key TEXT NOT NULL, vault BLOB NOT NULL, revision INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS account_sessions (token_hash BLOB PRIMARY KEY, username TEXT NOT NULL, expires INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS account_devices (username TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, last_seen INTEGER NOT NULL, PRIMARY KEY(username,id));
|
||||
CREATE TABLE IF NOT EXISTS account_device_sessions (token_hash BLOB PRIMARY KEY, username TEXT NOT NULL, device_id TEXT NOT NULL);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static byte[] HashVerifier(byte[] verifier, byte[] salt) => Rfc2898DeriveBytes.Pbkdf2(verifier, salt, 210_000, HashAlgorithmName.SHA256, 32);
|
||||
|
||||
public byte[] LoginSalt(string username)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using (var cleanup = _connection.CreateCommand())
|
||||
{
|
||||
cleanup.CommandText = "DELETE FROM account_sessions WHERE expires<=$n; DELETE FROM account_device_sessions WHERE token_hash NOT IN (SELECT token_hash FROM account_sessions);";
|
||||
cleanup.Parameters.AddWithValue("$n", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
cleanup.ExecuteNonQuery();
|
||||
}
|
||||
using var q = _connection.CreateCommand();
|
||||
q.CommandText = "SELECT salt FROM accounts WHERE username=$u";
|
||||
q.Parameters.AddWithValue("$u", username);
|
||||
var value = q.ExecuteScalar();
|
||||
byte[] salt = value is byte[] found ? found : RandomNumberGenerator.GetBytes(16);
|
||||
return salt;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Register(string username, byte[] salt, byte[] verifier, byte[] vault, string publicKey)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
var verifierSalt = RandomNumberGenerator.GetBytes(16);
|
||||
cmd.CommandText = "INSERT INTO accounts(username,salt,verifier,vault,revision,public_key,verifier_salt) VALUES($u,$s,$v,$b,1,$p,$vs)";
|
||||
cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$s", salt);
|
||||
cmd.Parameters.AddWithValue("$v", HashVerifier(verifier, verifierSalt)); cmd.Parameters.AddWithValue("$b", vault);
|
||||
cmd.Parameters.AddWithValue("$p", publicKey); cmd.Parameters.AddWithValue("$vs", verifierSalt);
|
||||
cmd.ExecuteNonQuery(); return true;
|
||||
}
|
||||
catch (SqliteException e) when (e.SqliteErrorCode == 19) { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
public string? Authenticate(string username, byte[] suppliedVerifier, string? deviceId = null, string? deviceName = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT verifier,verifier_salt FROM accounts WHERE username=$u";
|
||||
cmd.Parameters.AddWithValue("$u", username);
|
||||
using var r = cmd.ExecuteReader();
|
||||
bool found = r.Read();
|
||||
var verifier = found ? (byte[])r[0] : new byte[32];
|
||||
var verifierSalt = found ? (byte[])r[1] : new byte[16];
|
||||
r.Close();
|
||||
var expected = HashVerifier(suppliedVerifier, verifierSalt);
|
||||
if (!CryptographicOperations.FixedTimeEquals(verifier, expected) || !found) return null;
|
||||
if (deviceId is not null && (!Guid.TryParseExact(deviceId, "D", out _) || deviceName is null || deviceName.Length is < 1 or > 80 || deviceName.Any(char.IsControl))) return null;
|
||||
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
using var transaction = _connection.BeginTransaction();
|
||||
using var add = _connection.CreateCommand();
|
||||
add.Transaction = transaction;
|
||||
add.CommandText = "INSERT INTO account_sessions(token_hash,username,expires) VALUES($h,$u,$e)";
|
||||
add.Parameters.AddWithValue("$h", SHA256.HashData(Convert.FromBase64String(token)));
|
||||
add.Parameters.AddWithValue("$u", username); add.Parameters.AddWithValue("$e", DateTimeOffset.UtcNow.AddDays(30).ToUnixTimeSeconds());
|
||||
add.ExecuteNonQuery();
|
||||
if (deviceId is not null)
|
||||
{
|
||||
using var device = _connection.CreateCommand();
|
||||
device.Transaction = transaction;
|
||||
device.CommandText = """
|
||||
INSERT INTO account_devices(username,id,name,created_at,last_seen) VALUES($u,$d,$name,$now,$now)
|
||||
ON CONFLICT(username,id) DO UPDATE SET name=excluded.name,last_seen=excluded.last_seen;
|
||||
INSERT INTO account_device_sessions(token_hash,username,device_id) VALUES($h,$u,$d);
|
||||
""";
|
||||
device.Parameters.AddWithValue("$u", username); device.Parameters.AddWithValue("$d", deviceId);
|
||||
device.Parameters.AddWithValue("$name", deviceName!); device.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
device.Parameters.AddWithValue("$h", SHA256.HashData(Convert.FromBase64String(token)));
|
||||
device.ExecuteNonQuery();
|
||||
}
|
||||
transaction.Commit();
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasSession(string token, out string username)
|
||||
{
|
||||
username = "";
|
||||
if (token.Length != 44) return false;
|
||||
byte[] raw;
|
||||
try { raw = Convert.FromBase64String(token); } catch (FormatException) { return false; }
|
||||
if (raw.Length != 32) return false;
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT username FROM account_sessions WHERE token_hash=$h AND expires>$n";
|
||||
cmd.Parameters.AddWithValue("$h", SHA256.HashData(raw)); cmd.Parameters.AddWithValue("$n", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
var found = cmd.ExecuteScalar(); if (found is not string name) return false;
|
||||
username = name;
|
||||
using var seen = _connection.CreateCommand();
|
||||
seen.CommandText = """
|
||||
UPDATE account_devices SET last_seen=$now WHERE username=$u AND last_seen<$cutoff
|
||||
AND id=(SELECT device_id FROM account_device_sessions WHERE token_hash=$h)
|
||||
""";
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
seen.Parameters.AddWithValue("$now", now); seen.Parameters.AddWithValue("$cutoff", now - 60_000);
|
||||
seen.Parameters.AddWithValue("$u", name); seen.Parameters.AddWithValue("$h", SHA256.HashData(raw)); seen.ExecuteNonQuery();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void RevokeSession(string token)
|
||||
{
|
||||
if (token.Length != 44) return;
|
||||
byte[] raw;
|
||||
try { raw = Convert.FromBase64String(token); } catch (FormatException) { return; }
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM account_sessions WHERE token_hash=$h; DELETE FROM account_device_sessions WHERE token_hash=$h;";
|
||||
cmd.Parameters.AddWithValue("$h", SHA256.HashData(raw));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
public (byte[] Vault, long Revision)? GetVault(string username)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT vault,revision FROM accounts WHERE username=$u"; cmd.Parameters.AddWithValue("$u", username);
|
||||
using var r = cmd.ExecuteReader(); return r.Read() ? ((byte[])r[0], r.GetInt64(1)) : null;
|
||||
}
|
||||
}
|
||||
|
||||
public long? PutVault(string username, byte[] vault, long revision)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "UPDATE accounts SET vault=$v,revision=revision+1 WHERE username=$u AND revision=$r RETURNING revision";
|
||||
cmd.Parameters.AddWithValue("$v", vault); cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$r", revision);
|
||||
var updated = cmd.ExecuteScalar(); return updated is long value ? value : null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() { _connection.Dispose(); SqliteConnection.ClearPool(_connection); }
|
||||
|
||||
public string? SessionDevice(string token)
|
||||
{
|
||||
if (!HasSession(token, out _)) return null;
|
||||
lock (_gate)
|
||||
{
|
||||
using var q = _connection.CreateCommand();
|
||||
q.CommandText = "SELECT device_id FROM account_device_sessions WHERE token_hash=$h";
|
||||
q.Parameters.AddWithValue("$h", SHA256.HashData(Convert.FromBase64String(token)));
|
||||
return q.ExecuteScalar() as string;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<AccountDevice> Devices(string username, string? currentId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var q = _connection.CreateCommand();
|
||||
q.CommandText = """
|
||||
SELECT d.id,d.name,d.created_at,d.last_seen FROM account_devices d
|
||||
WHERE d.username=$u AND EXISTS (SELECT 1 FROM account_device_sessions ds JOIN account_sessions s ON s.token_hash=ds.token_hash
|
||||
WHERE ds.username=d.username AND ds.device_id=d.id AND s.expires>$now)
|
||||
ORDER BY d.last_seen DESC
|
||||
""";
|
||||
q.Parameters.AddWithValue("$u", username); q.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
using var r = q.ExecuteReader(); var result = new List<AccountDevice>();
|
||||
while (r.Read()) result.Add(new(r.GetString(0), r.GetString(1), r.GetInt64(2), r.GetInt64(3), r.GetString(0) == currentId));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public bool RenameDevice(string username, string id, string name)
|
||||
{
|
||||
if (name.Length is < 1 or > 80 || name.Any(char.IsControl)) return false;
|
||||
lock (_gate)
|
||||
{
|
||||
using var q = _connection.CreateCommand(); q.CommandText = "UPDATE account_devices SET name=$n WHERE username=$u AND id=$d";
|
||||
q.Parameters.AddWithValue("$n", name); q.Parameters.AddWithValue("$u", username); q.Parameters.AddWithValue("$d", id);
|
||||
return q.ExecuteNonQuery() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public void RevokeDevice(string username, string id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var q = _connection.CreateCommand();
|
||||
q.CommandText = """
|
||||
DELETE FROM account_sessions WHERE token_hash IN (SELECT token_hash FROM account_device_sessions WHERE username=$u AND device_id=$d);
|
||||
DELETE FROM account_device_sessions WHERE username=$u AND device_id=$d;
|
||||
DELETE FROM account_devices WHERE username=$u AND id=$d;
|
||||
""";
|
||||
q.Parameters.AddWithValue("$u", username); q.Parameters.AddWithValue("$d", id); q.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
public string? GetPublicKey(string username)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT public_key FROM accounts WHERE username=$u";
|
||||
cmd.Parameters.AddWithValue("$u", username); return cmd.ExecuteScalar() as string;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public sealed record AccountDevice(string Id, string Name, long CreatedAt, long LastSeen, bool Current);
|
||||
@@ -10,7 +10,7 @@ namespace WebDrop.Storage;
|
||||
/// </summary>
|
||||
public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
{
|
||||
private const int SchemaVersion = 3;
|
||||
private const int SchemaVersion = 1;
|
||||
|
||||
private readonly string _connectionString;
|
||||
private readonly SqliteConnection _writeConn;
|
||||
@@ -31,7 +31,20 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
|
||||
_writeConn = Open();
|
||||
Exec(_writeConn, "PRAGMA journal_mode=WAL;");
|
||||
Migrate();
|
||||
InitializeSchema();
|
||||
using (var columns = _writeConn.CreateCommand())
|
||||
{
|
||||
columns.CommandText = "SELECT COUNT(*) FROM pragma_table_info('seats') WHERE name='account_device_id'";
|
||||
if ((long)columns.ExecuteScalar()! == 0) Exec(_writeConn, "ALTER TABLE seats ADD COLUMN account_device_id TEXT;");
|
||||
}
|
||||
// Additive table: retain the running v2 database and its existing channel data.
|
||||
Exec(_writeConn, """
|
||||
CREATE TABLE IF NOT EXISTS channel_key_packages (
|
||||
channel_id BLOB NOT NULL, username TEXT NOT NULL, payload TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL, PRIMARY KEY (channel_id, username)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX IF NOT EXISTS ix_channel_keys_username ON channel_key_packages(username);
|
||||
""");
|
||||
}
|
||||
|
||||
public T Read<T>(Func<IResourceTx, T> action)
|
||||
@@ -65,7 +78,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
_writeConn.Dispose();
|
||||
SqliteConnection.ClearAllPools();
|
||||
SqliteConnection.ClearPool(_writeConn);
|
||||
}
|
||||
|
||||
private SqliteConnection Open()
|
||||
@@ -83,23 +96,19 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private void Migrate()
|
||||
private void InitializeSchema()
|
||||
{
|
||||
using var cmd = _writeConn.CreateCommand();
|
||||
cmd.CommandText = "PRAGMA user_version;";
|
||||
long version = (long)cmd.ExecuteScalar()!;
|
||||
if (version > SchemaVersion) throw new InvalidOperationException("数据库版本高于本程序支持的版本");
|
||||
if (version == SchemaVersion) return;
|
||||
|
||||
if (version != 0) throw new InvalidOperationException("存储版本不兼容当前应用契约");
|
||||
using var tx = _writeConn.BeginTransaction();
|
||||
if (version == 0) Exec(_writeConn, SchemaV1);
|
||||
if (version < 2) Exec(_writeConn, SchemaV2);
|
||||
if (version < 3) Exec(_writeConn, SchemaV3);
|
||||
Exec(_writeConn, Schema);
|
||||
Exec(_writeConn, $"PRAGMA user_version={SchemaVersion};");
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
private const string SchemaV1 = """
|
||||
private const string Schema = """
|
||||
CREATE TABLE channels (
|
||||
id BLOB NOT NULL PRIMARY KEY,
|
||||
mode INTEGER NOT NULL,
|
||||
@@ -111,7 +120,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_seq INTEGER NOT NULL DEFAULT 0,
|
||||
compacted_through INTEGER NOT NULL DEFAULT 0,
|
||||
bytes INTEGER NOT NULL DEFAULT 0
|
||||
bytes INTEGER NOT NULL DEFAULT 0,
|
||||
creator_account TEXT NOT NULL,
|
||||
join_password_hash BLOB,
|
||||
join_password_salt BLOB
|
||||
);
|
||||
CREATE INDEX ix_channels_expires ON channels(expires_at);
|
||||
|
||||
@@ -119,6 +131,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
channel_id BLOB NOT NULL,
|
||||
endpoint_hash BLOB NOT NULL,
|
||||
label BLOB,
|
||||
account_name TEXT,
|
||||
joined_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (channel_id, endpoint_hash)
|
||||
) WITHOUT ROWID;
|
||||
@@ -127,6 +140,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
channel_id BLOB NOT NULL,
|
||||
item_id BLOB NOT NULL,
|
||||
author_hash BLOB NOT NULL,
|
||||
author_account TEXT,
|
||||
flags INTEGER NOT NULL,
|
||||
max_downloads INTEGER NOT NULL,
|
||||
downloads INTEGER NOT NULL,
|
||||
@@ -137,7 +151,6 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
close_deadline INTEGER,
|
||||
label TEXT,
|
||||
PRIMARY KEY (channel_id, item_id)
|
||||
);
|
||||
CREATE INDEX ix_items_channel_created ON items(channel_id, created_at);
|
||||
@@ -168,9 +181,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
PRIMARY KEY (channel_id, seq)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX ix_log_item ON log(channel_id, item_id);
|
||||
""";
|
||||
|
||||
private const string SchemaV2 = """
|
||||
CREATE TABLE shares (
|
||||
id BLOB NOT NULL PRIMARY KEY,
|
||||
settings TEXT NOT NULL,
|
||||
@@ -181,7 +192,8 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
expires_at INTEGER NOT NULL,
|
||||
sealed_at INTEGER,
|
||||
manifest_item_id BLOB,
|
||||
bytes INTEGER NOT NULL DEFAULT 0
|
||||
bytes INTEGER NOT NULL DEFAULT 0,
|
||||
creator_account TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX ix_shares_expires ON shares(expires_at);
|
||||
|
||||
@@ -222,9 +234,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
PRIMARY KEY (share_id, item_id, frame_index)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX ix_share_frames_blob ON share_frames(blob_key) WHERE blob_key IS NOT NULL;
|
||||
""";
|
||||
|
||||
private const string SchemaV3 = """
|
||||
CREATE TABLE download_grants (
|
||||
resource_id BLOB NOT NULL,
|
||||
item_id BLOB NOT NULL,
|
||||
@@ -232,12 +242,18 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
PRIMARY KEY (resource_id, item_id, endpoint_hash)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX ix_download_grants_resource ON download_grants(resource_id);
|
||||
|
||||
CREATE TABLE channel_invitations(channel_id BLOB NOT NULL,username TEXT NOT NULL,payload TEXT NOT NULL,created_at INTEGER NOT NULL,PRIMARY KEY(channel_id,username));
|
||||
CREATE INDEX ix_channel_invitations_username ON channel_invitations(username);
|
||||
|
||||
CREATE TABLE channel_join_denials(channel_id BLOB NOT NULL,target TEXT NOT NULL,PRIMARY KEY(channel_id,target));
|
||||
CREATE TABLE channel_members(channel_id BLOB NOT NULL,username TEXT NOT NULL,role TEXT NOT NULL,joined_at INTEGER NOT NULL,PRIMARY KEY(channel_id,username));
|
||||
""";
|
||||
|
||||
private sealed class SqliteTx(SqliteConnection conn) : IResourceTx
|
||||
{
|
||||
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes";
|
||||
private const string ItemCols = "channel_id, item_id, author_hash, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline, label";
|
||||
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes, creator_account, join_password_hash, join_password_salt";
|
||||
private const string ItemCols = "channel_id, item_id, author_hash, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline, author_account";
|
||||
private const string FrameCols = "channel_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key";
|
||||
private const string ShareItemCols = "share_id, item_id, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline";
|
||||
private const string ShareFrameCols = "share_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key";
|
||||
@@ -291,10 +307,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
IdAt(r, 0),
|
||||
JsonSerializer.Deserialize(r.GetString(1), CoreJsonContext.Default.ShareSettings)!,
|
||||
Blob(r, 2), BlobOrNull(r, 3), BlobOrNull(r, 4), r.GetInt64(5), r.GetInt64(6),
|
||||
LongOrNull(r, 7), r.IsDBNull(8) ? null : IdAt(r, 8), r.GetInt64(9));
|
||||
LongOrNull(r, 7), r.IsDBNull(8) ? null : IdAt(r, 8), r.GetInt64(9), r.GetString(10));
|
||||
|
||||
public Share? GetShare(Id16 id) => One(
|
||||
"SELECT id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes FROM shares WHERE id=@id",
|
||||
"SELECT id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes,creator_account FROM shares WHERE id=@id",
|
||||
MapShare, ("@id", id.ToArray()));
|
||||
|
||||
public bool HasResourceId(Id16 id) => Scalar(
|
||||
@@ -302,17 +318,17 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
("@id", id.ToArray())) is not null;
|
||||
|
||||
public void InsertShare(Share s) => Run(
|
||||
"INSERT INTO shares (id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes) VALUES (@id,@settings,@read,@owner,@creator,@created,@expires,@sealed,@manifest,@bytes)",
|
||||
"INSERT INTO shares (id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes,creator_account) VALUES (@id,@settings,@read,@owner,@creator,@created,@expires,@sealed,@manifest,@bytes,@account)",
|
||||
("@id", s.Id.ToArray()), ("@settings", JsonSerializer.Serialize(s.Settings, CoreJsonContext.Default.ShareSettings)),
|
||||
("@read", s.ReadHash), ("@owner", s.OwnerHash), ("@creator", s.CreatorEndpointHash),
|
||||
("@created", s.CreatedAt), ("@expires", s.ExpiresAt), ("@sealed", s.SealedAt),
|
||||
("@manifest", s.ManifestItemId?.ToArray()), ("@bytes", s.Bytes));
|
||||
("@manifest", s.ManifestItemId?.ToArray()), ("@bytes", s.Bytes), ("@account", s.CreatorAccount));
|
||||
|
||||
public void UpdateShare(Share s) => Run(
|
||||
"UPDATE shares SET settings=@settings,owner_hash=@owner,expires_at=@expires,sealed_at=@sealed,manifest_item_id=@manifest,bytes=@bytes WHERE id=@id",
|
||||
"UPDATE shares SET settings=@settings,owner_hash=@owner,expires_at=@expires,sealed_at=@sealed,manifest_item_id=@manifest,bytes=@bytes,creator_account=@account WHERE id=@id",
|
||||
("@settings", JsonSerializer.Serialize(s.Settings, CoreJsonContext.Default.ShareSettings)), ("@owner", s.OwnerHash),
|
||||
("@expires", s.ExpiresAt), ("@sealed", s.SealedAt), ("@manifest", s.ManifestItemId?.ToArray()),
|
||||
("@bytes", s.Bytes), ("@id", s.Id.ToArray()));
|
||||
("@bytes", s.Bytes), ("@id", s.Id.ToArray()), ("@account", s.CreatorAccount));
|
||||
|
||||
public IReadOnlyList<Id16> ListExpiredShares(long now, int limit) => Many(
|
||||
"SELECT id FROM shares WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit));
|
||||
@@ -400,18 +416,18 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
BlobOrNull(r, 3),
|
||||
BlobOrNull(r, 4),
|
||||
Blob(r, 5),
|
||||
r.GetInt64(6), r.GetInt64(7), r.GetInt64(8), r.GetInt64(9), r.GetInt64(10));
|
||||
r.GetInt64(6), r.GetInt64(7), r.GetInt64(8), r.GetInt64(9), r.GetInt64(10), r.GetString(11), BlobOrNull(r, 12), BlobOrNull(r, 13));
|
||||
|
||||
public Channel? GetChannel(Id16 id) =>
|
||||
One($"SELECT {ChannelCols} FROM channels WHERE id=@id", MapChannel, ("@id", id.ToArray()));
|
||||
|
||||
public void InsertChannel(Channel c) => Run(
|
||||
$"INSERT INTO channels ({ChannelCols}) VALUES (@id,@mode,@settings,@rh,@wh,@oh,@created,@expires,@seq,@comp,@bytes)",
|
||||
$"INSERT INTO channels ({ChannelCols}) VALUES (@id,@mode,@settings,@rh,@wh,@oh,@created,@expires,@seq,@comp,@bytes,@account,@ph,@ps)",
|
||||
("@id", c.Id.ToArray()), ("@mode", (int)c.Mode),
|
||||
("@settings", JsonSerializer.Serialize(c.Settings, CoreJsonContext.Default.ChannelSettings)),
|
||||
("@rh", c.ReadHash), ("@wh", c.WriteHash), ("@oh", c.OwnerHash),
|
||||
("@created", c.CreatedAt), ("@expires", c.ExpiresAt),
|
||||
("@seq", c.LastSeq), ("@comp", c.CompactedThrough), ("@bytes", c.Bytes));
|
||||
("@seq", c.LastSeq), ("@comp", c.CompactedThrough), ("@bytes", c.Bytes), ("@account", c.CreatorAccount), ("@ph", c.JoinPasswordHash), ("@ps", c.JoinPasswordSalt));
|
||||
|
||||
public void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt) => Run(
|
||||
"UPDATE channels SET settings=@s, expires_at=@e WHERE id=@id",
|
||||
@@ -421,6 +437,36 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
public void TouchChannel(Id16 id, long expiresAt) =>
|
||||
Run("UPDATE channels SET expires_at=@e WHERE id=@id", ("@e", expiresAt), ("@id", id.ToArray()));
|
||||
|
||||
public void SetJoinPassword(Id16 id, byte[]? hash, byte[]? salt) => Run("UPDATE channels SET join_password_hash=@h,join_password_salt=@s WHERE id=@id", ("@h", hash), ("@s", salt), ("@id", id.ToArray()));
|
||||
public bool HasInvitation(Id16 id, string username) => Scalar("SELECT 1 FROM channel_invitations WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username)) is not null;
|
||||
public void PutInvitation(ChannelInvitation i) => Run("INSERT INTO channel_invitations(channel_id,username,payload,created_at) VALUES(@id,@u,@p,@t) ON CONFLICT(channel_id,username) DO UPDATE SET payload=excluded.payload,created_at=excluded.created_at", ("@id", i.ChannelId.ToArray()), ("@u", i.Username), ("@p", i.Payload), ("@t", i.CreatedAt));
|
||||
private static ChannelInvitation MapInvitation(SqliteDataReader r) => new(IdAt(r, 0), r.GetString(1), r.GetString(2), r.GetInt64(3));
|
||||
public IReadOnlyList<ChannelInvitation> ListInvitations(Id16 id) => Many("SELECT channel_id,username,payload,created_at FROM channel_invitations WHERE channel_id=@id", MapInvitation, ("@id", id.ToArray()));
|
||||
public IReadOnlyList<ChannelInvitation> ListUserInvitations(string username) => Many("SELECT i.channel_id,i.username,i.payload,i.created_at FROM channel_invitations i JOIN channels c ON c.id=i.channel_id WHERE i.username=@u AND c.expires_at>@now AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id=i.channel_id AND m.username=i.username)", MapInvitation, ("@u", username), ("@now", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()));
|
||||
public void DeleteInvitation(Id16 id, string username) => Run("DELETE FROM channel_invitations WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username));
|
||||
public void PutKeyPackage(Id16 id, string username, string payload, long createdAt) => Run(
|
||||
"INSERT INTO channel_key_packages(channel_id,username,payload,created_at) VALUES(@id,@u,@p,@t) ON CONFLICT(channel_id,username) DO UPDATE SET payload=excluded.payload,created_at=excluded.created_at",
|
||||
("@id", id.ToArray()), ("@u", username), ("@p", payload), ("@t", createdAt));
|
||||
public void DeleteKeyPackage(Id16 id, string username) => Run("DELETE FROM channel_key_packages WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username));
|
||||
public IReadOnlyList<ChannelKeyPackage> ListUserKeyPackages(string username, long now) => Many(
|
||||
"""
|
||||
SELECT k.channel_id,k.username,m.role,k.payload,k.created_at FROM channel_key_packages k
|
||||
JOIN channels c ON c.id=k.channel_id
|
||||
JOIN channel_members m ON m.channel_id=k.channel_id AND m.username=k.username
|
||||
WHERE k.username=@u AND c.expires_at>@now
|
||||
""", r => new ChannelKeyPackage(IdAt(r, 0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetInt64(4)),
|
||||
("@u", username), ("@now", now));
|
||||
public void DeleteAccountSeats(Id16 id, string username) => Run("DELETE FROM seats WHERE channel_id=@id AND account_name=@u", ("@id", id.ToArray()), ("@u", username));
|
||||
private static ChannelMember MapMember(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetInt64(2));
|
||||
public ChannelMember? GetChannelMember(Id16 id, string username) => One("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id AND username=@u", MapMember, ("@id", id.ToArray()), ("@u", username));
|
||||
public IReadOnlyList<ChannelMember> ListChannelMembers(Id16 id) => Many("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id ORDER BY joined_at,username", MapMember, ("@id", id.ToArray()));
|
||||
public void PutChannelMember(Id16 id, ChannelMember member) => Run("INSERT OR IGNORE INTO channel_members(channel_id,username,role,joined_at) VALUES(@id,@u,@r,@t)", ("@id", id.ToArray()), ("@u", member.Username), ("@r", member.Role), ("@t", member.JoinedAt));
|
||||
public void DeleteChannelMember(Id16 id, string username) => Run("DELETE FROM channel_members WHERE channel_id=@id AND username=@u AND role<>'owner'", ("@id", id.ToArray()), ("@u", username));
|
||||
public bool IsJoinDenied(Id16 id, string? username, byte[] endpointHash) => Scalar("SELECT 1 FROM channel_join_denials WHERE channel_id=@id AND (target=@a OR target=@e) LIMIT 1", ("@id", id.ToArray()), ("@a", username is null ? null : "account:" + username), ("@e", "endpoint:" + Codec.Encode(endpointHash))) is not null;
|
||||
public void DenyAccount(Id16 id, string username) => Run("INSERT OR IGNORE INTO channel_join_denials VALUES(@id,@t)", ("@id", id.ToArray()), ("@t", "account:" + username));
|
||||
public void AllowAccount(Id16 id, string username) => Run("DELETE FROM channel_join_denials WHERE channel_id=@id AND target=@t", ("@id", id.ToArray()), ("@t", "account:" + username));
|
||||
public void DenyEndpoint(Id16 id, byte[] endpointHash) => Run("INSERT OR IGNORE INTO channel_join_denials VALUES(@id,@t)", ("@id", id.ToArray()), ("@t", "endpoint:" + Codec.Encode(endpointHash)));
|
||||
|
||||
public void AddChannelBytes(Id16 id, long delta) =>
|
||||
Run("UPDATE channels SET bytes=bytes+@d WHERE id=@id", ("@d", delta), ("@id", id.ToArray()));
|
||||
|
||||
@@ -439,6 +485,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
Run("DELETE FROM items WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM seats WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM channels WHERE id=@c", ("@c", key));
|
||||
Run("DELETE FROM channel_invitations WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM channel_key_packages WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM channel_members WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM channel_join_denials WHERE channel_id=@c", ("@c", key));
|
||||
}
|
||||
|
||||
public IReadOnlyList<Id16> ListExpiredChannels(long now, int limit) =>
|
||||
@@ -446,18 +496,18 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
|
||||
// ───────── 席位 ─────────
|
||||
|
||||
private static Seat MapSeat(SqliteDataReader r) => new(Blob(r, 0), r.GetInt64(1), BlobOrNull(r, 2));
|
||||
private static Seat MapSeat(SqliteDataReader r) => new(Blob(r, 0), r.GetInt64(1), BlobOrNull(r, 2), r.IsDBNull(3) ? null : r.GetString(3), r.IsDBNull(4) ? null : r.GetString(4));
|
||||
|
||||
public Seat? GetSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => One(
|
||||
"SELECT endpoint_hash, joined_at, label FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
|
||||
"SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
|
||||
MapSeat, ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray()));
|
||||
|
||||
public int CountSeats(Id16 channelId) =>
|
||||
(int)(long)Scalar("SELECT COUNT(*) FROM seats WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
|
||||
|
||||
public void InsertSeat(Id16 channelId, Seat seat) => Run(
|
||||
"INSERT INTO seats (channel_id, endpoint_hash, label, joined_at) VALUES (@c,@e,@l,@j)",
|
||||
("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt));
|
||||
"INSERT INTO seats (channel_id, endpoint_hash, label, joined_at,account_name,account_device_id) VALUES (@c,@e,@l,@j,@a,@d)",
|
||||
("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt), ("@a", seat.AccountName), ("@d", seat.AccountDeviceId));
|
||||
|
||||
public bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => Run(
|
||||
"DELETE FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
|
||||
@@ -468,27 +518,36 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
("@l", label), ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray()));
|
||||
|
||||
public IReadOnlyList<Seat> ListSeats(Id16 channelId) => Many(
|
||||
"SELECT endpoint_hash, joined_at, label FROM seats WHERE channel_id=@c ORDER BY joined_at, endpoint_hash",
|
||||
"SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c ORDER BY joined_at, endpoint_hash",
|
||||
MapSeat, ("@c", channelId.ToArray()));
|
||||
|
||||
public IReadOnlyList<Id16> DeleteDeviceSeats(string username, string deviceId)
|
||||
{
|
||||
var ids = Many("SELECT DISTINCT channel_id FROM seats WHERE account_name=@u AND account_device_id=@d", r => IdAt(r, 0), ("@u", username), ("@d", deviceId));
|
||||
Run("DELETE FROM seats WHERE account_name=@u AND account_device_id=@d", ("@u", username), ("@d", deviceId));
|
||||
return ids;
|
||||
}
|
||||
public void BindSeatDevice(Id16 id, byte[] endpointHash, string deviceId) => Run(
|
||||
"UPDATE seats SET account_device_id=@d WHERE channel_id=@c AND endpoint_hash=@e",
|
||||
("@d", deviceId), ("@c", id.ToArray()), ("@e", endpointHash));
|
||||
|
||||
// ───────── 条目 ─────────
|
||||
|
||||
private static Item MapItem(SqliteDataReader r) => new(
|
||||
IdAt(r, 0), IdAt(r, 1), Blob(r, 2), (FrameFlags)r.GetInt32(3),
|
||||
r.GetInt64(4), r.GetInt64(5), r.GetInt32(6), r.GetInt64(7), LongOrNull(r, 8),
|
||||
r.GetInt64(9), r.GetInt64(10), r.GetInt64(11), LongOrNull(r, 12),
|
||||
r.IsDBNull(13) ? null : r.GetString(13));
|
||||
r.GetInt64(9), r.GetInt64(10), r.GetInt64(11), LongOrNull(r, 12), r.IsDBNull(13) ? null : r.GetString(13));
|
||||
|
||||
public Item? GetItem(Id16 channelId, Id16 itemId) => One(
|
||||
$"SELECT {ItemCols} FROM items WHERE channel_id=@c AND item_id=@i",
|
||||
MapItem, ("@c", channelId.ToArray()), ("@i", itemId.ToArray()));
|
||||
|
||||
public void InsertItem(Item i) => Run(
|
||||
$"INSERT INTO items ({ItemCols}) VALUES (@c,@i,@a,@f,@md,@d,@fc,@mi,@fi,@b,@cr,@ex,@cd,@l)",
|
||||
$"INSERT INTO items ({ItemCols}) VALUES (@c,@i,@a,@f,@md,@d,@fc,@mi,@fi,@b,@cr,@ex,@cd,@account)",
|
||||
("@c", i.ChannelId.ToArray()), ("@i", i.ItemId.ToArray()), ("@a", i.AuthorHash),
|
||||
("@f", (int)i.Flags), ("@md", i.MaxDownloads), ("@d", i.Downloads),
|
||||
("@fc", i.FrameCount), ("@mi", i.MaxIndex), ("@fi", i.FinalIndex),
|
||||
("@b", i.Bytes), ("@cr", i.CreatedAt), ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@l", i.Label));
|
||||
("@b", i.Bytes), ("@cr", i.CreatedAt), ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@account", i.AuthorAccount));
|
||||
|
||||
public void UpdateItem(Item i) => Run(
|
||||
"UPDATE items SET downloads=@d, frame_count=@fc, max_index=@mi, final_index=@fi, bytes=@b, expires_at=@ex, close_deadline=@cd WHERE channel_id=@c AND item_id=@i",
|
||||
@@ -507,10 +566,11 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
}
|
||||
|
||||
public int CountItems(Id16 channelId) =>
|
||||
(int)(long)Scalar("SELECT COUNT(*) FROM items WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
|
||||
(int)(long)Scalar("SELECT COUNT(*) FROM items WHERE channel_id=@c AND (flags & 16)=0", ("@c", channelId.ToArray()))!;
|
||||
public int CountControlItems(Id16 channelId) => (int)(long)Scalar("SELECT COUNT(*) FROM items WHERE channel_id=@c AND (flags & 16)<>0", ("@c", channelId.ToArray()))!;
|
||||
|
||||
public IReadOnlyList<Item> ListOldestItems(Id16 channelId, int limit) => Many(
|
||||
$"SELECT {ItemCols} FROM items WHERE channel_id=@c ORDER BY created_at, rowid LIMIT @n",
|
||||
$"SELECT {ItemCols} FROM items WHERE channel_id=@c AND (flags & 16)=0 ORDER BY created_at, rowid LIMIT @n",
|
||||
MapItem, ("@c", channelId.ToArray()), ("@n", limit));
|
||||
|
||||
public Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId) => One(
|
||||
|
||||
Reference in new issue
Block a user