feat(api): bind encrypted workspaces to accounts and devices

This commit is contained in:
justme committed 2026-10-13 01:38:19 +08:00
1 parent 019b15041a
commit c7d8ef15bc
49 files changed
+1904 -278

No files matched your search

+1 -1
View File
@@ -3,5 +3,5 @@ namespace WebDrop.Api;
public static class ApiContract
{
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
public const string Version = "1.0.0";
public const string Version = "2.1.0";
}
+90
View File
@@ -47,6 +47,80 @@ public sealed class LimitsBody
[JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; }
}
public sealed class AccountRegisterRequest
{
[JsonPropertyName("public_key")] public required string PublicKey { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("salt")] public required string Salt { get; init; }
[JsonPropertyName("verifier")] public required string Verifier { get; init; }
[JsonPropertyName("vault")] public required string Vault { get; init; }
}
public sealed class AccountSaltRequest { [JsonPropertyName("username")] public required string Username { get; init; } }
public sealed class AccountSessionRequest
{
[JsonPropertyName("device_id")] public string? DeviceId { get; init; }
[JsonPropertyName("device_name")] public string? DeviceName { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("verifier")] public required string Verifier { get; init; }
}
public sealed class AccountVaultRequest
{
[JsonPropertyName("revision")] public required long Revision { get; init; }
[JsonPropertyName("vault")] public required string Vault { get; init; }
}
public sealed class AccountSaltBody
{
[JsonPropertyName("salt")] public required string Salt { get; init; }
}
public sealed class AccountSessionBody
{
[JsonPropertyName("token")] public required string Token { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
}
public sealed class AccountVaultBody
{
[JsonPropertyName("revision")] public required long Revision { get; init; }
[JsonPropertyName("vault")] public required string Vault { get; init; }
}
public sealed class PublicKeyBody { [JsonPropertyName("public_key")] public required string PublicKey { get; init; } }
public sealed class InvitationRequest
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("payload")] public required string Payload { get; init; }
}
public sealed class InvitationBody
{
[JsonPropertyName("channel_id")] public required string ChannelId { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("payload")] public required string Payload { get; init; }
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
}
public sealed class JoinRequest { [JsonPropertyName("password")] public string? Password { get; init; } }
public sealed class KeyPackageRequest { [JsonPropertyName("payload")] public required string Payload { get; init; } }
public sealed class AccountDeviceNameRequest { [JsonPropertyName("name")] public required string Name { get; init; } }
public sealed class AccountDeviceBody
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
[JsonPropertyName("last_seen")] public required long LastSeen { get; init; }
[JsonPropertyName("current")] public required bool Current { get; init; }
}
public sealed class AccountChannelBody
{
[JsonPropertyName("channel_id")] public required string ChannelId { get; init; }
[JsonPropertyName("role")] public required string Role { get; init; }
[JsonPropertyName("payload")] public required string Payload { get; init; }
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
}
public sealed class MemberBody
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("role")] public required string Role { get; init; }
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
}
[JsonSerializable(typeof(ErrorBody))]
[JsonSerializable(typeof(GoneLogBody))]
[JsonSerializable(typeof(ServerInfoBody))]
@@ -62,5 +136,21 @@ public sealed class LimitsBody
[JsonSerializable(typeof(AppendResult))]
[JsonSerializable(typeof(SiteverifyRequest))]
[JsonSerializable(typeof(SiteverifyResponse))]
[JsonSerializable(typeof(AccountRegisterRequest))]
[JsonSerializable(typeof(AccountSaltRequest))]
[JsonSerializable(typeof(AccountSessionRequest))]
[JsonSerializable(typeof(AccountVaultRequest))]
[JsonSerializable(typeof(AccountSaltBody))]
[JsonSerializable(typeof(AccountSessionBody))]
[JsonSerializable(typeof(AccountVaultBody))]
[JsonSerializable(typeof(PublicKeyBody))]
[JsonSerializable(typeof(InvitationRequest))]
[JsonSerializable(typeof(List<InvitationBody>))]
[JsonSerializable(typeof(JoinRequest))]
[JsonSerializable(typeof(List<MemberBody>))]
[JsonSerializable(typeof(KeyPackageRequest))]
[JsonSerializable(typeof(List<AccountChannelBody>))]
[JsonSerializable(typeof(AccountDeviceNameRequest))]
[JsonSerializable(typeof(List<AccountDeviceBody>))]
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public sealed partial class ApiJson : JsonSerializerContext;
@@ -0,0 +1,91 @@
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static partial class Endpoints
{
private static void MapAccounts(RouteGroupBuilder api, SqliteAccountStore accounts)
{
api.MapPost("/accounts", async (HttpContext ctx, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountRegisterRequest, ct);
var username = NormalizeUsername(req.Username);
var salt = DecodeBase64(req.Salt, 16, 16);
var verifier = DecodeBase64(req.Verifier, 32, 32);
var vault = DecodeBase64(req.Vault, 28, 1_300_000);
ValidatePublicKey(req.PublicKey);
if (!accounts.Register(username, salt, verifier, vault, req.PublicKey)) return Json(new ErrorBody { Error = "username_unavailable" }, ApiJson.Default.ErrorBody, StatusCodes.Status409Conflict);
return Results.NoContent();
}).RequireRateLimiting(CreatePolicy);
api.MapPost("/accounts/salt", async (HttpContext ctx, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSaltRequest, ct);
var username = NormalizeUsername(req.Username);
var salt = accounts.LoginSalt(username);
return Json(new AccountSaltBody { Salt = Convert.ToBase64String(salt) }, ApiJson.Default.AccountSaltBody);
}).RequireRateLimiting(CreatePolicy);
api.MapPost("/accounts/session", async (HttpContext ctx, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSessionRequest, ct);
var username = NormalizeUsername(req.Username);
var verifier = DecodeBase64(req.Verifier, 32, 32);
var token = accounts.Authenticate(username, verifier, req.DeviceId, req.DeviceName);
if (token is null) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized);
return Json(new AccountSessionBody { Token = token, Username = username }, ApiJson.Default.AccountSessionBody);
}).RequireRateLimiting(CreatePolicy);
api.MapGet("/accounts/vault", (HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out var username)) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized);
var vault = accounts.GetVault(username);
return vault is null ? Results.NotFound() : Json(new AccountVaultBody { Revision = vault.Value.Revision, Vault = Convert.ToBase64String(vault.Value.Vault) }, ApiJson.Default.AccountVaultBody);
});
api.MapGet("/accounts/devices", (HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var current = accounts.SessionDevice(ctx.Request.Headers["X-WD-Account"].ToString());
var devices = accounts.Devices(username, current).Select(d => new AccountDeviceBody
{ Id = d.Id, Name = d.Name, CreatedAt = d.CreatedAt, LastSeen = d.LastSeen, Current = d.Current }).ToList();
return Json(devices, ApiJson.Default.ListAccountDeviceBody);
});
api.MapPatch("/accounts/devices/{id}", async (string id, HttpContext ctx, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountDeviceNameRequest, ct);
if (req.Name is null || !accounts.RenameDevice(username, id, req.Name.Trim())) throw new WebDropException(ErrorCode.BadRequest);
return Results.NoContent();
});
api.MapDelete("/accounts/devices/{id}", (string id, HttpContext ctx, ChannelService svc) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
accounts.RevokeDevice(username, id);
svc.ReleaseAccountDevice(username, id);
return Results.NoContent();
});
api.MapPut("/accounts/vault", async (HttpContext ctx, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized);
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountVaultRequest, ct);
if (req.Revision < 0) return Results.BadRequest();
var vault = DecodeBase64(req.Vault, 28, 1_300_000);
var next = accounts.PutVault(username, vault, req.Revision);
return next is null ? Json(new ErrorBody { Error = "vault_conflict" }, ApiJson.Default.ErrorBody, StatusCodes.Status409Conflict) : Json(new AccountVaultBody { Revision = next.Value, Vault = req.Vault }, ApiJson.Default.AccountVaultBody);
});
api.MapDelete("/accounts/session", (HttpContext ctx) =>
{
var accountToken = ctx.Request.Headers["X-WD-Account"].ToString();
if (accountToken.Length > 0) { accounts.RevokeSession(accountToken); return Results.NoContent(); }
var header = ctx.Request.Headers["Authorization"].ToString();
if (!header.StartsWith("Bearer ", StringComparison.Ordinal)) return Results.NoContent();
accounts.RevokeSession(header[7..]);
return Results.NoContent();
});
}
}
@@ -0,0 +1,54 @@
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static partial class Endpoints
{
private static void MapChannelAccess(RouteGroupBuilder api, SqliteAccountStore accounts)
{
api.MapGet("/accounts/channels", (HttpContext ctx, ChannelService svc) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var packages = svc.AccountChannels(username).Select(k => new AccountChannelBody
{
ChannelId = k.ChannelId.ToBase64Url(), Role = k.Role, Payload = k.Payload, CreatedAt = k.CreatedAt,
}).ToList();
return Json(packages, ApiJson.Default.ListAccountChannelBody);
});
api.MapPut("/channels/{id}/key-package", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.KeyPackageRequest, ct);
svc.SaveOwnerKeyPackage(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Payload);
return Results.NoContent();
});
api.MapGet("/accounts/users/{username}/key", (string username, HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out _)) throw new WebDropException(ErrorCode.AccountRequired);
var key = accounts.GetPublicKey(NormalizeUsername(username));
if (key is null) throw new WebDropException(ErrorCode.NotFound);
return Json(new PublicKeyBody { PublicKey = key }, ApiJson.Default.PublicKeyBody);
});
api.MapGet("/accounts/invitations", (HttpContext ctx, ChannelService svc) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
return Json(InvitationBodies(svc.UserInvitations(username)), ApiJson.Default.ListInvitationBody);
});
api.MapGet("/channels/{id}/invitations", (string id, HttpContext ctx, ChannelService svc) =>
Json(InvitationBodies(svc.Invitations(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.ListInvitationBody));
api.MapGet("/channels/{id}/members", (string id, HttpContext ctx, ChannelService svc) =>
Json(svc.Members(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true)).Select(m => new MemberBody { Username = m.Username, Role = m.Role, JoinedAt = m.JoinedAt }).ToList(), ApiJson.Default.ListMemberBody));
api.MapPost("/channels/{id}/invitations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.InvitationRequest, ct);
var username = NormalizeUsername(req.Username);
if (accounts.GetPublicKey(username) is null) throw new WebDropException(ErrorCode.NotFound);
svc.InviteUser(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), username, req.Payload); return Results.NoContent();
});
api.MapDelete("/channels/{id}/invitations/{username}", (string id, string username, HttpContext ctx, ChannelService svc) =>
{
svc.RevokeInvitation(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), NormalizeUsername(username)); return Results.NoContent();
});
}
}
+90 -11
View File
@@ -4,17 +4,18 @@ using System.Net.WebSockets;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static class Endpoints
public static partial class Endpoints
{
public const string CreatePolicy = "create";
private const string LastSeqHeader = "X-WD-Last-Seq";
private const string OctetStream = "application/octet-stream";
private static readonly TimeSpan MaxLongPoll = TimeSpan.FromSeconds(25);
public static void MapApi(this IEndpointRouteBuilder app, WebDropOptions options, ICaptchaVerifier captcha)
public static void MapApi(this IEndpointRouteBuilder app, WebDropOptions options, ICaptchaVerifier captcha, SqliteAccountStore accounts)
{
var api = app.MapGroup("/api/v1");
@@ -37,15 +38,18 @@ public static class Endpoints
},
}, ApiJson.Default.ServerInfoBody));
MapAccounts(api, accounts);
// ───────── 频道 ─────────
api.MapPost("/channels", async (HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.CreateChannelRequest, ct);
var endpoint = RequestAuth.ParseEndpointHash(ctx.Request.Headers[RequestAuth.EndpointHeader]);
string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString();
await captcha.VerifyAsync(token.Length > 0 ? token : null, ct);
return Json(svc.CreateChannel(req, endpoint), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created);
return Json(svc.CreateChannel(req, endpoint, username, accounts.SessionDevice(ctx.Request.Headers["X-WD-Account"].ToString())), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created);
}).RequireRateLimiting(CreatePolicy);
api.MapGet("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) =>
@@ -66,8 +70,13 @@ public static class Endpoints
// ───────── 席位 ─────────
api.MapPost("/channels/{id}/seats", (string id, HttpContext ctx, ChannelService svc) =>
Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true)), ApiJson.Default.SeatJoined));
api.MapPost("/channels/{id}/seats", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var request = ctx.Request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) == true ? await ReadJson(ctx.Request, ApiJson.Default.JoinRequest, ct) : new JoinRequest();
return Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), request.Password), ApiJson.Default.SeatJoined);
});
MapChannelAccess(api, accounts);
api.MapGet("/channels/{id}/seats", (string id, HttpContext ctx, ChannelService svc) =>
{
@@ -83,6 +92,14 @@ public static class Endpoints
return Results.NoContent();
});
api.MapPut("/channels/{id}/seats/{endpointHash}/label", async (string id, string endpointHash, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) =>
{
if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest);
byte[] label = await ReadBody(ctx.Request, limits.MaxSeatLabelBytes, ct);
svc.SetSeatLabelForEndpoint(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash, label);
return Results.NoContent();
});
api.MapDelete("/channels/{id}/seats/{endpointHash}", (string id, string endpointHash, HttpContext ctx, ChannelService svc) =>
{
if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest);
@@ -97,10 +114,9 @@ public static class Endpoints
var channelId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString());
string label = ctx.Request.Headers["X-WD-Label"].ToString();
byte[] body = await ReadBody(ctx.Request, Envelope.HeaderSize + limits.MaxPayloadBytes, ct);
var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, label.Length > 0 ? label : null, ct);
var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, ct);
return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK);
});
@@ -131,7 +147,7 @@ public static class Endpoints
throw new WebDropException(ErrorCode.BadRequest);
var channelId = ParseId(id);
var creds = RequestAuth.FromSubprotocols(ctx.WebSockets.WebSocketRequestedProtocols);
var creds = RequestAuth.FromSubprotocols(ctx.WebSockets.WebSocketRequestedProtocols, accounts);
long from = QueryLong(ctx.Request, "from", 0);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted);
@@ -143,7 +159,7 @@ public static class Endpoints
ws = await ctx.WebSockets.AcceptWebSocketAsync("wd.v1");
_ = WatchClose(ws, cts);
return new WebSocketSink(ws);
}, cts.Token);
}, cts.Token, () => StreamAccountValid(ctx, accounts, creds));
}
finally
{
@@ -166,7 +182,7 @@ public static class Endpoints
ctx.Response.Headers["X-Accel-Buffering"] = "no";
await ctx.Response.StartAsync();
return new SseSink(ctx.Response);
}, ctx.RequestAborted);
}, ctx.RequestAborted, () => StreamAccountValid(ctx, accounts, creds));
return Results.Empty;
});
@@ -211,11 +227,12 @@ public static class Endpoints
api.MapPost("/shares", async (HttpContext ctx, ShareService svc, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.CreateShareRequest, ct);
string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString();
await captcha.VerifyAsync(token.Length > 0 ? token : null, ct);
byte[] endpoint = RequestAuth.ParseEndpointHashOptional(ctx.Request.Headers[RequestAuth.EndpointHeader]);
return Json(svc.Create(req, endpoint.Length == 0 ? null : endpoint), ApiJson.Default.ShareInfo, StatusCodes.Status201Created);
return Json(svc.Create(req, endpoint.Length == 0 ? null : endpoint, username), ApiJson.Default.ShareInfo, StatusCodes.Status201Created);
}).RequireRateLimiting(CreatePolicy);
api.MapPatch("/shares/{id}/settings", async (string id, HttpContext ctx, ShareService svc, CancellationToken ct) =>
@@ -287,6 +304,68 @@ public static class Endpoints
private static IResult Json<T>(T value, JsonTypeInfo<T> info, int? status = null) =>
Results.Json(value, info, statusCode: status);
private static bool TryAccount(HttpContext ctx, SqliteAccountStore accounts, out string username)
{
username = "";
var accountToken = ctx.Request.Headers["X-WD-Account"].ToString();
if (accountToken.Length > 0) return accounts.HasSession(accountToken, out username);
var header = ctx.Request.Headers["Authorization"].ToString();
return header.StartsWith("Bearer ", StringComparison.Ordinal) && accounts.HasSession(header[7..], out username);
}
private static bool StreamAccountValid(HttpContext ctx, SqliteAccountStore accounts, Credentials creds)
{
if (creds.AccountName is null) return true;
string token = ctx.Request.Headers["X-WD-Account"].ToString();
if (ctx.WebSockets.IsWebSocketRequest)
{
var protocol = ctx.WebSockets.WebSocketRequestedProtocols.FirstOrDefault(p => p.StartsWith("wd.account.", StringComparison.Ordinal));
if (protocol is null || !Codec.TryDecodeExact(protocol[11..], 32, out var raw)) return false;
token = Convert.ToBase64String(raw);
}
return accounts.HasSession(token, out var username) && username == creds.AccountName;
}
private static string NormalizeUsername(string? value)
{
if (value is null) throw new WebDropException(ErrorCode.BadRequest);
var name = value.Trim().ToLowerInvariant();
if (name.Length is < 3 or > 32 || name.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '_' or '-')))
throw new WebDropException(ErrorCode.BadRequest);
return name;
}
private static List<InvitationBody> InvitationBodies(IReadOnlyList<ChannelInvitation> invitations) => invitations.Select(i => new InvitationBody
{
ChannelId = i.ChannelId.ToBase64Url(), Username = i.Username, Payload = i.Payload, CreatedAt = i.CreatedAt,
}).ToList();
private static void ValidatePublicKey(string? value)
{
if (value is null) throw new WebDropException(ErrorCode.BadRequest);
if (value.Length > 1024) throw new WebDropException(ErrorCode.BadRequest);
try
{
using var doc = JsonDocument.Parse(value); var root = doc.RootElement;
if (root.GetProperty("kty").GetString() != "EC" || root.GetProperty("crv").GetString() != "P-256"
|| !Codec.TryDecodeExact(root.GetProperty("x").GetString(), 32, out _)
|| !Codec.TryDecodeExact(root.GetProperty("y").GetString(), 32, out _) || root.TryGetProperty("d", out _))
throw new WebDropException(ErrorCode.BadRequest);
}
catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException) { throw new WebDropException(ErrorCode.BadRequest); }
}
private static byte[] DecodeBase64(string? value, int minLength, int maxLength)
{
if (value is null || value.Length > ((maxLength + 2) / 3 * 4)) throw new WebDropException(ErrorCode.BadRequest);
try
{
var bytes = Convert.FromBase64String(value);
return bytes.Length >= minLength && bytes.Length <= maxLength ? bytes : throw new WebDropException(ErrorCode.BadRequest);
}
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
}
private static Id16 ParseId(string text) =>
Id16.TryParse(text, out var id) ? id : throw new WebDropException(ErrorCode.BadRequest);
+2 -1
View File
@@ -25,7 +25,7 @@ public static class LogStreamer
/// </summary>
public static async Task RunAsync(
ChannelService svc, ChannelHub hub, Id16 id, Credentials creds, long from,
Func<Task<ILogSink>> open, CancellationToken ct)
Func<Task<ILogSink>> open, CancellationToken ct, Func<bool>? validateAccount = null)
{
using var sub = hub.Subscribe(id);
ILogSink? sink = null;
@@ -36,6 +36,7 @@ public static class LogStreamer
{
while (!ct.IsCancellationRequested)
{
if (validateAccount is not null && !validateAccount()) throw new WebDropException(ErrorCode.AccountRequired);
var changed = sub.Changed; // 先取任务再读状态:读取期间的发布不会丢
var page = await svc.ReadLogAsync(id, creds, cursor, PageSize, ct).ConfigureAwait(false);
sink ??= await open().ConfigureAwait(false);
+28 -4
View File
@@ -1,4 +1,5 @@
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
@@ -23,28 +24,51 @@ public static class RequestAuth
throw new WebDropException(ErrorCode.BadRequest);
}
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint));
string? accountName = null;
string? deviceId = null;
var accountToken = req.Headers["X-WD-Account"].ToString();
if (accountToken.Length > 0)
{
var accounts = req.HttpContext.RequestServices.GetRequiredService<SqliteAccountStore>();
if (!accounts.HasSession(accountToken, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
accountName = username;
deviceId = accounts.SessionDevice(accountToken);
}
if (role == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint), accountName, deviceId);
}
/// <summary>
/// 浏览器的 WebSocket 不能设请求头,凭据放在子协议里:
/// wd.v1, wd.role.&lt;role&gt;, wd.auth.&lt;b64url&gt;, wd.ep.&lt;b64url&gt;
/// </summary>
public static Credentials FromSubprotocols(IEnumerable<string> protocols)
public static Credentials FromSubprotocols(IEnumerable<string> protocols, SqliteAccountStore? accounts = null)
{
string? role = null, auth = null, endpoint = null;
string? role = null, auth = null, endpoint = null, accountToken = null;
foreach (string p in protocols)
{
if (p.StartsWith("wd.role.", StringComparison.Ordinal)) role = p["wd.role.".Length..];
else if (p.StartsWith("wd.auth.", StringComparison.Ordinal)) auth = p["wd.auth.".Length..];
else if (p.StartsWith("wd.ep.", StringComparison.Ordinal)) endpoint = p["wd.ep.".Length..];
else if (p.StartsWith("wd.account.", StringComparison.Ordinal)) accountToken = p["wd.account.".Length..];
}
byte[]? token = null;
if (auth is not null && !Codec.TryDecodeExact(auth, TokenHash.Size, out token))
throw new WebDropException(ErrorCode.BadRequest);
return new Credentials(ParseRole(role), token, ParseEndpoint(endpoint, requireEndpoint: true));
string? accountName = null;
string? deviceId = null;
if (accountToken is not null)
{
if (accounts is null || !Codec.TryDecodeExact(accountToken, 32, out var raw)
|| !accounts.HasSession(Convert.ToBase64String(raw), out var username)) throw new WebDropException(ErrorCode.AccountRequired);
accountName = username;
deviceId = accounts.SessionDevice(Convert.ToBase64String(raw));
}
var parsedRole = ParseRole(role);
if (parsedRole == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: true), accountName, deviceId);
}
public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true);
+6 -4
View File
@@ -24,8 +24,9 @@ public static class WebDropApp
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(options.Limits);
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<IBlobStore>(_ => new DiskBlobStore(Path.Combine(options.DataDir, "blobs")));
builder.Services.AddSingleton<SqliteChannelStore>(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "webdrop.db")));
builder.Services.AddSingleton<IBlobStore>(_ => new DiskBlobStore(Path.Combine(options.DataDir, "v2", "blobs")));
builder.Services.AddSingleton<SqliteChannelStore>(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "v2", "webdrop.db")));
builder.Services.AddSingleton<SqliteAccountStore>(_ => new SqliteAccountStore(Path.Combine(options.DataDir, "v2", "webdrop.db")));
builder.Services.AddSingleton<IResourceStore>(sp => sp.GetRequiredService<SqliteChannelStore>());
builder.Services.AddSingleton<ICaptchaVerifier>(sp => CreateCaptchaVerifier(options.Captcha, sp));
builder.Services.AddSingleton<ChannelHub>();
@@ -42,7 +43,7 @@ public static class WebDropApp
.WithOrigins(options.CorsOrigins)
.AllowAnyMethod()
.WithHeaders("Authorization", "Content-Type", "Last-Event-ID",
"X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Label", "X-WD-Max-Downloads")
"X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Max-Downloads", "X-WD-Account")
.WithExposedHeaders("X-WD-Last-Seq")
.SetPreflightMaxAge(TimeSpan.FromHours(1))));
}
@@ -64,6 +65,7 @@ public static class WebDropApp
});
var app = builder.Build();
_ = app.Services.GetRequiredService<SqliteChannelStore>();
if (options.TrustForwardedHeaders)
{
@@ -82,7 +84,7 @@ public static class WebDropApp
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat });
app.UseRateLimiter();
EmbeddedFrontend.Use(app, options.Captcha.ApiEndpoint);
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>());
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>(), app.Services.GetRequiredService<SqliteAccountStore>());
return app;
}
+21
View File
@@ -92,6 +92,26 @@ public interface IResourceTx
Channel? GetChannel(Id16 id);
void InsertChannel(Channel channel);
void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt);
void SetJoinPassword(Id16 id, byte[]? hash, byte[]? salt);
bool HasInvitation(Id16 id, string username);
void PutInvitation(ChannelInvitation invitation);
IReadOnlyList<ChannelInvitation> ListInvitations(Id16 id);
IReadOnlyList<ChannelInvitation> ListUserInvitations(string username);
void DeleteInvitation(Id16 id, string username);
void PutKeyPackage(Id16 id, string username, string payload, long createdAt);
void DeleteKeyPackage(Id16 id, string username);
IReadOnlyList<ChannelKeyPackage> ListUserKeyPackages(string username, long now);
IReadOnlyList<Id16> DeleteDeviceSeats(string username, string deviceId);
void BindSeatDevice(Id16 id, byte[] endpointHash, string deviceId);
void DeleteAccountSeats(Id16 id, string username);
ChannelMember? GetChannelMember(Id16 id, string username);
IReadOnlyList<ChannelMember> ListChannelMembers(Id16 id);
void PutChannelMember(Id16 id, ChannelMember member);
void DeleteChannelMember(Id16 id, string username);
bool IsJoinDenied(Id16 id, string? username, byte[] endpointHash);
void DenyAccount(Id16 id, string username);
void AllowAccount(Id16 id, string username);
void DenyEndpoint(Id16 id, byte[] endpointHash);
void TouchChannel(Id16 id, long expiresAt);
void AddChannelBytes(Id16 id, long delta);
void SetCompactedThrough(Id16 id, long seq);
@@ -113,6 +133,7 @@ public interface IResourceTx
void UpdateItem(Item item);
void DeleteItemRows(Id16 channelId, Id16 itemId);
int CountItems(Id16 channelId);
int CountControlItems(Id16 channelId);
IReadOnlyList<Item> ListOldestItems(Id16 channelId, int limit);
Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId);
IReadOnlyList<Item> ListExpiredItems(long now, int limit);
@@ -0,0 +1,99 @@
using System.Security.Cryptography;
namespace WebDrop.Core;
public sealed partial class ChannelService
{
private static (byte[]? Hash, byte[]? Salt) HashJoinPassword(string? password)
{
if (string.IsNullOrEmpty(password)) return (null, null);
if (password.Length is < 8 or > 256) throw new WebDropException(ErrorCode.BadRequest);
var salt = RandomNumberGenerator.GetBytes(16);
return (Rfc2898DeriveBytes.Pbkdf2(password, salt, 210_000, HashAlgorithmName.SHA256, 32), salt);
}
private static void CheckAdmission(IResourceTx tx, Channel channel, Credentials creds, string? password)
{
if (creds.AccountName is not null && creds.AccountName == channel.CreatorAccount) return;
if (tx.IsJoinDenied(channel.Id, creds.AccountName, creds.EndpointHash)) throw new WebDropException(ErrorCode.Forbidden);
bool invited = creds.AccountName is not null && tx.HasInvitation(channel.Id, creds.AccountName);
if (invited) return;
if (creds.AccountName is not null && tx.GetChannelMember(channel.Id, creds.AccountName) is not null) return;
if (channel.Settings.JoinPolicy == "invite") throw new WebDropException(ErrorCode.InvitationRequired);
if (!channel.Settings.AllowGuests && creds.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired);
if (channel.JoinPasswordHash is null) return;
if (string.IsNullOrEmpty(password)) throw new WebDropException(ErrorCode.ChannelPasswordRequired);
if (password.Length > 256) throw new WebDropException(ErrorCode.ChannelPasswordInvalid);
var actual = Rfc2898DeriveBytes.Pbkdf2(password, channel.JoinPasswordSalt!, 210_000, HashAlgorithmName.SHA256, 32);
if (!CryptographicOperations.FixedTimeEquals(actual, channel.JoinPasswordHash)) throw new WebDropException(ErrorCode.ChannelPasswordInvalid);
}
public void InviteUser(Id16 id, Credentials creds, string username, string payload)
{
if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
tx.PutInvitation(new ChannelInvitation(id, username, payload, clock.NowMs));
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
tx.PutKeyPackage(id, username, payload, clock.NowMs);
tx.AllowAccount(id, username);
return 0;
});
}
public IReadOnlyList<ChannelInvitation> Invitations(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
return tx.ListInvitations(id);
});
public IReadOnlyList<ChannelInvitation> UserInvitations(string username) => store.Read(tx => tx.ListUserInvitations(username));
public void RevokeInvitation(Id16 id, Credentials creds, string username)
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
tx.DeleteInvitation(id, username);
tx.DeleteKeyPackage(id, username);
tx.DeleteAccountSeats(id, username);
tx.DeleteChannelMember(id, username);
tx.DenyAccount(id, username);
return 0;
});
events.SeatsChanged(id);
}
public IReadOnlyList<ChannelMember> Members(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
if (principal.Role != Role.Owner) _ = Authenticate(tx, id, creds, SeatRule.Required);
return tx.ListChannelMembers(id);
});
public IReadOnlyList<ChannelKeyPackage> AccountChannels(string username) =>
store.Read(tx => tx.ListUserKeyPackages(username, clock.NowMs));
public void ReleaseAccountDevice(string username, string deviceId)
{
var channels = store.Write(tx => tx.DeleteDeviceSeats(username, deviceId));
foreach (var id in channels) events.SeatsChanged(id);
}
public void SaveOwnerKeyPackage(Id16 id, Credentials creds, string payload)
{
if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
tx.PutKeyPackage(id, principal.AccountName!, payload, clock.NowMs);
return 0;
});
}
}
+3 -1
View File
@@ -19,7 +19,9 @@ public static class ChannelPolicy
return channel.Settings.DeletePolicy switch
{
DeletePolicy.Anyone => true,
DeletePolicy.Own => item.AuthorHash.AsSpan().SequenceEqual(p.EndpointHash),
DeletePolicy.Own => item.AuthorAccount is not null
? item.AuthorAccount == p.AccountName
: item.AuthorHash.AsSpan().SequenceEqual(p.EndpointHash),
_ => false,
};
}
+67 -17
View File
@@ -6,7 +6,7 @@ namespace WebDrop.Core;
/// 频道的全部业务逻辑。依赖抽象(存储、blob、时钟、事件),不含 HTTP、SQL、文件细节。
/// 约定:所有失败以 <see cref="WebDropException"/> 表达;提交成功后才发事件;blob 先写后提交。
/// </summary>
public sealed class ChannelService(
public sealed partial class ChannelService(
IResourceStore store,
IBlobStore blobs,
IClock clock,
@@ -17,8 +17,10 @@ public sealed class ChannelService(
// ───────────────────────── 频道 ─────────────────────────
public ChannelInfo CreateChannel(CreateChannelRequest req, byte[] endpointHash)
public ChannelInfo CreateChannel(CreateChannelRequest req, byte[] endpointHash, string creatorAccount, string? accountDeviceId = null)
{
if (string.IsNullOrWhiteSpace(creatorAccount)) throw new WebDropException(ErrorCode.AccountRequired);
if (req.KeyPackage is not null && req.KeyPackage.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
if (!Id16.TryParse(req.Id, out var id)) throw new WebDropException(ErrorCode.BadRequest);
if (req.Mode is not { } mode) throw new WebDropException(ErrorCode.BadRequest);
@@ -44,14 +46,17 @@ public sealed class ChannelService(
throw new WebDropException(ErrorCode.BadRequest);
long now = clock.NowMs;
var (passwordHash, passwordSalt) = HashJoinPassword(req.JoinPassword);
var channel = new Channel(id, mode, settings, readHash, writeHash, ownerHash,
now, now + settings.TtlSeconds * 1000L, 0, 0, 0);
now, now + settings.TtlSeconds * 1000L, 0, 0, 0, creatorAccount, passwordHash, passwordSalt);
store.Write(tx =>
{
if (tx.HasResourceId(id)) throw new WebDropException(ErrorCode.Conflict);
tx.InsertChannel(channel);
tx.InsertSeat(id, new Seat(endpointHash, now, null));
tx.PutChannelMember(id, new ChannelMember(creatorAccount, "owner", now));
if (req.KeyPackage is not null) tx.PutKeyPackage(id, creatorAccount, req.KeyPackage, now);
tx.InsertSeat(id, new Seat(endpointHash, now, null, creatorAccount, accountDeviceId));
return 0;
});
return ToInfo(channel, 1);
@@ -77,6 +82,12 @@ public sealed class ChannelService(
? clock.NowMs + updated.TtlSeconds * 1000L
: channel.ExpiresAt;
tx.UpdateChannel(id, updated, expiresAt);
if (patch.JoinPassword is not null)
{
var (hash, salt) = HashJoinPassword(patch.JoinPassword);
tx.SetJoinPassword(id, hash, salt);
channel = channel with { JoinPasswordHash = hash, JoinPasswordSalt = salt };
}
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountSeats(id));
});
events.SettingsChanged(id);
@@ -98,19 +109,28 @@ public sealed class ChannelService(
// ───────────────────────── 席位 ─────────────────────────
public SeatJoined Join(Id16 id, Credentials creds)
public SeatJoined Join(Id16 id, Credentials creds, string? password = null)
{
var (joined, isNew) = store.Write(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
var existing = tx.GetSeat(id, principal.EndpointHash);
if (existing?.AccountName is not null && existing.AccountName != creds.AccountName) throw new WebDropException(ErrorCode.AccountRequired);
if (existing?.AccountDeviceId is not null && existing.AccountDeviceId != creds.AccountDeviceId) throw new WebDropException(ErrorCode.AccountRequired);
int used = tx.CountSeats(id);
if (existing is not null)
{
if (existing.AccountName is not null && existing.AccountDeviceId is null && creds.AccountDeviceId is not null)
tx.BindSeatDevice(id, principal.EndpointHash, creds.AccountDeviceId);
return (new SeatJoined { EndpointHash = Codec.Encode(existing.EndpointHash), JoinedAt = existing.JoinedAt, SeatsUsed = used, SeatsMax = channel.Settings.Seats }, false);
}
CheckAdmission(tx, channel, creds, password);
if (used >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull);
long now = clock.NowMs;
tx.InsertSeat(id, new Seat(principal.EndpointHash, now, null));
tx.InsertSeat(id, new Seat(principal.EndpointHash, now, null, creds.AccountName, creds.AccountDeviceId));
if (creds.AccountName is not null) tx.PutChannelMember(id, new ChannelMember(creds.AccountName, "member", now));
return (new SeatJoined { EndpointHash = Codec.Encode(principal.EndpointHash), JoinedAt = now, SeatsUsed = used + 1, SeatsMax = channel.Settings.Seats }, true);
});
if (isNew) events.SeatsChanged(id);
@@ -126,13 +146,13 @@ public sealed class ChannelService(
EndpointHash = Codec.Encode(s.EndpointHash),
JoinedAt = s.JoinedAt,
Label = s.Label is null ? null : Codec.Encode(s.Label),
AccountName = s.AccountName,
}).ToList();
});
public void SetSeatLabel(Id16 id, Credentials creds, byte[] label)
{
if (label.Length == 0) throw new WebDropException(ErrorCode.BadRequest);
if (label.Length > limits.MaxSeatLabelBytes) throw new WebDropException(ErrorCode.TooLarge);
ValidateSeatLabel(label);
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required);
@@ -142,6 +162,26 @@ public sealed class ChannelService(
events.SeatsChanged(id);
}
public void SetSeatLabelForEndpoint(Id16 id, Credentials creds, byte[] endpointHash, byte[] label)
{
ValidateSeatLabel(label);
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
if (tx.GetSeat(id, endpointHash) is null) throw new WebDropException(ErrorCode.NotFound);
tx.SetSeatLabel(id, endpointHash, label);
return 0;
});
events.SeatsChanged(id);
}
private void ValidateSeatLabel(byte[] label)
{
if (label.Length == 0) throw new WebDropException(ErrorCode.BadRequest);
if (label.Length > limits.MaxSeatLabelBytes) throw new WebDropException(ErrorCode.TooLarge);
}
public void ReleaseSeat(Id16 id, Credentials creds, byte[] endpointHash)
{
store.Write(tx =>
@@ -149,6 +189,7 @@ public sealed class ChannelService(
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
if (!tx.DeleteSeat(id, endpointHash)) throw new WebDropException(ErrorCode.NotFound);
tx.DenyEndpoint(id, endpointHash);
return 0;
});
events.SeatsChanged(id);
@@ -158,12 +199,10 @@ public sealed class ChannelService(
public async Task<AppendResult> AppendFrameAsync(
Id16 channelId, Credentials creds, ReadOnlyMemory<byte> body,
long? maxDownloads, string? label, CancellationToken ct = default)
long? maxDownloads, CancellationToken ct = default)
{
if (!Envelope.TryParse(body.Span, out var env)) throw new WebDropException(ErrorCode.BadRequest);
if (env.PayloadLength > limits.MaxPayloadBytes) throw new WebDropException(ErrorCode.TooLarge);
if (label is not null && System.Text.Encoding.UTF8.GetByteCount(label) > limits.MaxLabelBytes)
throw new WebDropException(ErrorCode.BadRequest);
ValidateShape(env);
byte[] digest = SHA256.HashData(body.Span);
@@ -189,7 +228,7 @@ public sealed class ChannelService(
AppendOutcome outcome;
try
{
outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads, label));
outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads));
}
catch
{
@@ -212,7 +251,7 @@ public sealed class ChannelService(
private AppendOutcome AppendInTx(
IResourceTx tx, Id16 channelId, Credentials creds, Envelope env, ReadOnlyMemory<byte> body,
byte[] digest, string? blobKey, long? maxDownloads, string? label)
byte[] digest, string? blobKey, long? maxDownloads)
{
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
CheckWritable(channel, principal, env);
@@ -227,10 +266,11 @@ public sealed class ChannelService(
if (env.FrameIndex == 0)
{
if (env.IsControl && tx.CountControlItems(channelId) >= 4096) throw new WebDropException(ErrorCode.TooLarge);
if (item is not null) throw new WebDropException(ErrorCode.BadRequest);
long max = env.IsGated ? Math.Clamp(maxDownloads ?? channel.Settings.FileMaxDownloads, 0, 1_000_000) : 0;
item = new Item(channelId, env.ItemId, principal.EndpointHash, env.ItemFlags, max, 0, 0, -1, null, 0,
now, now + ttlMs, null, label);
now, env.IsControl ? long.MaxValue : now + ttlMs, null, principal.AccountName);
tx.InsertItem(item);
}
else
@@ -305,6 +345,7 @@ public sealed class ChannelService(
private void ValidateShape(Envelope env)
{
if (env.IsControl && (env.IsGated || env.IsSnapshot || env.FrameIndex != 0 || !env.IsFinal || env.PayloadLength > 16_400)) throw new WebDropException(ErrorCode.BadRequest);
// 非 GATED 条目只能是单帧且必须 FINAL;快照只能是非 GATED 的单帧
if (!env.IsGated && (env.FrameIndex != 0 || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest);
if (env.IsSnapshot && env.IsGated) throw new WebDropException(ErrorCode.BadRequest);
@@ -541,9 +582,16 @@ public sealed class ChannelService(
if (channel is null || channel.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.NotFound);
var role = VerifyRole(channel, creds);
if (seat == SeatRule.Required && tx.GetSeat(id, creds.EndpointHash) is null)
throw new WebDropException(ErrorCode.NotJoined);
return (channel, new Principal(role, creds.EndpointHash));
var member = creds.AccountName is null ? null : tx.GetChannelMember(id, creds.AccountName);
if (role == Role.Owner && member?.Role != "owner") throw new WebDropException(ErrorCode.AccountRequired);
if (seat == SeatRule.Required)
{
var endpoint = tx.GetSeat(id, creds.EndpointHash);
if (endpoint is null) throw new WebDropException(ErrorCode.NotJoined);
if (endpoint.AccountName is not null && (endpoint.AccountName != creds.AccountName || member is null)) throw new WebDropException(ErrorCode.AccountRequired);
if (endpoint.AccountDeviceId is not null && endpoint.AccountDeviceId != creds.AccountDeviceId) throw new WebDropException(ErrorCode.AccountRequired);
}
return (channel, new Principal(role, creds.EndpointHash, creds.AccountName));
}
private static Role VerifyRole(Channel channel, Credentials creds)
@@ -573,6 +621,8 @@ public sealed class ChannelService(
private static ChannelInfo ToInfo(Channel channel, int seatsUsed) => new()
{
Id = channel.Id.ToBase64Url(),
CreatorAccount = channel.CreatorAccount,
PasswordRequired = channel.JoinPasswordHash is not null,
Mode = channel.Mode,
Settings = channel.Settings,
SeatsUsed = seatsUsed,
+6
View File
@@ -5,6 +5,8 @@ namespace WebDrop.Core;
public sealed class CreateChannelRequest
{
[JsonPropertyName("key_package")] public string? KeyPackage { get; init; }
[JsonPropertyName("join_password")] public string? JoinPassword { get; init; }
[JsonPropertyName("id")] public string? Id { get; init; }
[JsonPropertyName("mode")] public ChannelMode? Mode { get; init; }
[JsonPropertyName("settings")] public ChannelSettings? Settings { get; init; }
@@ -40,6 +42,7 @@ public sealed class ShareInfo
public sealed class ShareItemInfo
{
[JsonPropertyName("complete")] public bool Complete { get; init; }
[JsonPropertyName("item_id")] public required string ItemId { get; init; }
[JsonPropertyName("bytes")] public required long Bytes { get; init; }
[JsonPropertyName("frames")] public required int Frames { get; init; }
@@ -49,6 +52,8 @@ public sealed class ShareItemInfo
public sealed class ChannelInfo
{
[JsonPropertyName("password_required")] public bool PasswordRequired { get; init; }
[JsonPropertyName("creator_account")] public string? CreatorAccount { get; init; }
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
@@ -72,6 +77,7 @@ public sealed class SeatJoined
public sealed class SeatInfo
{
[JsonPropertyName("account_name")] public string? AccountName { get; init; }
[JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; }
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
[JsonPropertyName("label")] public string? Label { get; init; }
+1
View File
@@ -34,6 +34,7 @@ public enum FrameFlags : byte
Snapshot = 2,
Gated = 4,
Enc = 8,
Control = 16,
}
public enum LogKind : byte
+3 -2
View File
@@ -17,7 +17,7 @@ public readonly record struct Envelope(FrameFlags Flags, Id16 ItemId, uint Frame
if (data.Length < HeaderSize || data[0] != Version) return false;
byte flags = data[1];
if ((flags & 0xF0) != 0) return false;
if ((flags & 0xE0) != 0) return false;
uint payloadLen = BinaryPrimitives.ReadUInt32BigEndian(data[22..]);
if (payloadLen > int.MaxValue || data.Length != HeaderSize + (int)payloadLen) return false;
@@ -34,9 +34,10 @@ public readonly record struct Envelope(FrameFlags Flags, Id16 ItemId, uint Frame
public bool IsGated => (Flags & FrameFlags.Gated) != 0;
public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0;
public bool IsEnc => (Flags & FrameFlags.Enc) != 0;
public bool IsControl => (Flags & FrameFlags.Control) != 0;
/// <summary>条目级标志:同一条目的所有帧必须一致。</summary>
public FrameFlags ItemFlags => Flags & (FrameFlags.Snapshot | FrameFlags.Gated);
public FrameFlags ItemFlags => Flags & (FrameFlags.Snapshot | FrameFlags.Gated | FrameFlags.Control);
}
public static class PayloadTiers
+10
View File
@@ -17,6 +17,10 @@ public enum ErrorCode
RateLimited,
VisitorsFull,
Sealed,
AccountRequired,
InvitationRequired,
ChannelPasswordRequired,
ChannelPasswordInvalid,
}
/// <summary>预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。</summary>
@@ -53,6 +57,10 @@ public static class ErrorCodes
ErrorCode.RateLimited => "rate_limited",
ErrorCode.VisitorsFull => "visitors_full",
ErrorCode.Sealed => "sealed",
ErrorCode.AccountRequired => "account_required",
ErrorCode.InvitationRequired => "invitation_required",
ErrorCode.ChannelPasswordRequired => "channel_password_required",
ErrorCode.ChannelPasswordInvalid => "channel_password_invalid",
_ => "bad_request",
};
@@ -70,6 +78,8 @@ public static class ErrorCodes
ErrorCode.CaptchaUnavailable => 503,
ErrorCode.RateLimited => 429,
ErrorCode.VisitorsFull => 403,
ErrorCode.AccountRequired => 401,
ErrorCode.InvitationRequired or ErrorCode.ChannelPasswordRequired or ErrorCode.ChannelPasswordInvalid => 403,
_ => 400,
};
}
+13 -6
View File
@@ -11,7 +11,10 @@ public sealed record Channel(
long ExpiresAt,
long LastSeq,
long CompactedThrough,
long Bytes);
long Bytes,
string CreatorAccount,
byte[]? JoinPasswordHash = null,
byte[]? JoinPasswordSalt = null);
public sealed record Share(
Id16 Id,
@@ -23,7 +26,8 @@ public sealed record Share(
long ExpiresAt,
long? SealedAt,
Id16? ManifestItemId,
long Bytes)
long Bytes,
string CreatorAccount)
{
public bool IsSealed => SealedAt is not null;
}
@@ -71,14 +75,17 @@ public sealed record Item(
long CreatedAt,
long ExpiresAt,
long? CloseDeadline,
string? Label)
string? AuthorAccount)
{
public bool IsGated => (Flags & FrameFlags.Gated) != 0;
public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0;
public bool IsComplete => FinalIndex is { } f && FrameCount == f + 1;
}
public sealed record Seat(byte[] EndpointHash, long JoinedAt, byte[]? Label);
public sealed record Seat(byte[] EndpointHash, long JoinedAt, byte[]? Label, string? AccountName = null, string? AccountDeviceId = null);
public sealed record ChannelInvitation(Id16 ChannelId, string Username, string Payload, long CreatedAt);
public sealed record ChannelMember(string Username, string Role, long JoinedAt);
public sealed record ChannelKeyPackage(Id16 ChannelId, string Username, string Role, string Payload, long CreatedAt);
/// <summary>已存储的一帧(完整封装)。Inline 与 BlobKey 二选一。</summary>
public sealed record StoredFrame(
@@ -94,7 +101,7 @@ public sealed record StoredFrame(
public sealed record LogEntry(long Seq, LogKind Kind, Id16 ItemId, uint FrameIndex, byte Aux, long CreatedAt);
/// <summary>一次请求验证通过后的身份。</summary>
public sealed record Principal(Role Role, byte[] EndpointHash);
public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null);
/// <summary>请求携带的凭据,尚未验证。</summary>
public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash);
public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash, string? AccountName = null, string? AccountDeviceId = null);
+22 -11
View File
@@ -15,22 +15,26 @@ public sealed class ServerLimits
public int InlineThresholdBytes { get; set; } = 64 * 1024;
public int DownloadGraceSeconds { get; set; } = 300;
public int TombstoneRetentionSeconds { get; set; } = 604_800;
public int MaxLabelBytes { get; set; } = 255;
public int MaxSeatLabelBytes { get; set; } = 256;
}
public sealed class ChannelSettings
{
[JsonPropertyName("seats")] public int Seats { get; init; } = 2;
[JsonPropertyName("members_can_upload")] public bool MembersCanUpload { get; init; } = true;
[JsonPropertyName("delete_policy")] public DeletePolicy DeletePolicy { get; init; } = DeletePolicy.Own;
[JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; init; } = 86_400;
[JsonPropertyName("max_items")] public int MaxItems { get; init; } = 1000;
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; init; } = 10;
[JsonPropertyName("ui_restrict_copy_download")] public bool UiRestrictCopyDownload { get; init; }
[JsonPropertyName("join_policy")] public string JoinPolicy { get; set; } = "open";
[JsonPropertyName("allow_guests")] public bool AllowGuests { get; set; } = true;
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("seats")] public int Seats { get; set; } = 2;
[JsonPropertyName("members_can_upload")] public bool MembersCanUpload { get; set; } = true;
[JsonPropertyName("delete_policy")] public DeletePolicy DeletePolicy { get; set; } = DeletePolicy.Own;
[JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; set; } = 86_400;
[JsonPropertyName("max_items")] public int MaxItems { get; set; } = 1000;
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; set; } = 10;
[JsonPropertyName("ui_restrict_copy_download")] public bool UiRestrictCopyDownload { get; set; }
public void Validate(ServerLimits limits)
{
if (JoinPolicy is not ("open" or "invite")) throw new WebDropException(ErrorCode.BadRequest);
if (Name is { } name && (name.Length > 48 || name.Any(char.IsControl))) throw new WebDropException(ErrorCode.BadRequest);
if (Seats < 1 || Seats > limits.MaxSeats) throw new WebDropException(ErrorCode.BadRequest);
if (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds) throw new WebDropException(ErrorCode.BadRequest);
if (MaxItems < 1 || MaxItems > limits.MaxItemsLimit) throw new WebDropException(ErrorCode.BadRequest);
@@ -40,6 +44,10 @@ public sealed class ChannelSettings
public sealed class ChannelSettingsPatch
{
[JsonPropertyName("join_policy")] public string? JoinPolicy { get; init; }
[JsonPropertyName("allow_guests")] public bool? AllowGuests { get; init; }
[JsonPropertyName("join_password")] public string? JoinPassword { get; init; }
[JsonPropertyName("name")] public string? Name { get; init; }
[JsonPropertyName("seats")] public int? Seats { get; init; }
[JsonPropertyName("members_can_upload")] public bool? MembersCanUpload { get; init; }
[JsonPropertyName("delete_policy")] public DeletePolicy? DeletePolicy { get; init; }
@@ -50,6 +58,9 @@ public sealed class ChannelSettingsPatch
public ChannelSettings ApplyTo(ChannelSettings s) => new()
{
JoinPolicy = JoinPolicy ?? s.JoinPolicy,
AllowGuests = AllowGuests ?? s.AllowGuests,
Name = Name is null ? s.Name : string.IsNullOrWhiteSpace(Name) ? null : Name.Trim(),
Seats = Seats ?? s.Seats,
MembersCanUpload = MembersCanUpload ?? s.MembersCanUpload,
DeletePolicy = DeletePolicy ?? s.DeletePolicy,
@@ -62,9 +73,9 @@ public sealed class ChannelSettingsPatch
public sealed class ShareSettings
{
[JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; init; } = 86_400;
[JsonPropertyName("max_visitors")] public int MaxVisitors { get; init; }
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; init; } = 10;
[JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; set; } = 86_400;
[JsonPropertyName("max_visitors")] public int MaxVisitors { get; set; }
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; set; } = 10;
public void Validate(ServerLimits limits)
{
+12 -6
View File
@@ -10,8 +10,9 @@ public sealed class ShareService(
ServerLimits limits,
int draftTtlSeconds)
{
public ShareInfo Create(CreateShareRequest req, byte[]? creatorEndpointHash)
public ShareInfo Create(CreateShareRequest req, byte[]? creatorEndpointHash, string creatorAccount)
{
if (string.IsNullOrWhiteSpace(creatorAccount)) throw new WebDropException(ErrorCode.AccountRequired);
if (!Id16.TryParse(req.Id, out var id)
|| !Codec.TryDecodeExact(req.ReadHash, TokenHash.Size, out var readHash)
|| !Codec.TryDecodeExact(req.OwnerHash, TokenHash.Size, out var ownerHash))
@@ -21,7 +22,7 @@ public sealed class ShareService(
settings.Validate(limits);
long now = clock.NowMs;
var share = new Share(id, settings, readHash, ownerHash, creatorEndpointHash, now,
now + Math.Max(60, draftTtlSeconds) * 1000L, null, null, 0);
now + Math.Max(60, draftTtlSeconds) * 1000L, null, null, 0, creatorAccount);
store.Write(tx =>
{
@@ -48,6 +49,7 @@ public sealed class ShareService(
return ToInfo(share, tx.ListShareItems(id));
});
public ShareInfo UpdateSettings(Id16 id, Credentials creds, ShareSettingsPatch patch) => store.Write(tx =>
{
var share = Draft(tx.GetShare(id), creds);
@@ -66,7 +68,7 @@ public sealed class ShareService(
throw new WebDropException(ErrorCode.TooLarge);
if (!env.IsEnc) throw new WebDropException(ErrorCode.ModeMismatch);
if (!PayloadTiers.IsValidEncPayloadLength(env.PayloadLength)) throw new WebDropException(ErrorCode.BadRequest);
if (env.IsSnapshot) throw new WebDropException(ErrorCode.BadRequest);
if (env.IsSnapshot || env.IsControl) throw new WebDropException(ErrorCode.BadRequest);
if (!env.IsGated && (env.FrameIndex != 0 || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest);
if (maxDownloads is < 0) throw new WebDropException(ErrorCode.BadRequest);
@@ -198,7 +200,7 @@ public sealed class ShareService(
var frame = store.Write(tx =>
{
var share = SealedForRead(tx.GetShare(id), creds);
RegisterVisitor(tx, share, creds.EndpointHash);
RegisterVisitor(tx, share, creds.EndpointHash, creds.AccountName);
var manifestId = share.ManifestItemId ?? throw new WebDropException(ErrorCode.NotFound);
return tx.GetShareFrame(id, manifestId, 0) ?? throw new WebDropException(ErrorCode.Gone);
});
@@ -217,7 +219,7 @@ public sealed class ShareService(
long now = clock.NowMs;
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline))
throw new WebDropException(ErrorCode.Gone);
RegisterVisitor(tx, share, creds.EndpointHash);
RegisterVisitor(tx, share, creds.EndpointHash, creds.AccountName);
if (fromIndex == 0)
{
if (item.MaxDownloads > 0 && item.Downloads >= item.MaxDownloads) throw new WebDropException(ErrorCode.Gone);
@@ -310,6 +312,8 @@ public sealed class ShareService(
private void RequireOwner(Share share, Credentials creds)
{
if (share.CreatorAccount != creds.AccountName)
throw new WebDropException(ErrorCode.AccountRequired);
if (creds.ClaimedRole != Role.Owner || !TokenHash.Matches(creds.Token ?? [], share.OwnerHash))
throw new WebDropException(ErrorCode.Unauthorized);
}
@@ -320,8 +324,9 @@ public sealed class ShareService(
return candidate;
}
private void RegisterVisitor(IResourceTx tx, Share share, byte[] endpointHash)
private void RegisterVisitor(IResourceTx tx, Share share, byte[] endpointHash, string? accountName)
{
if (accountName == share.CreatorAccount) return;
if (share.CreatorEndpointHash is { } creator && CryptographicOperations.FixedTimeEquals(creator, endpointHash)) return;
if (tx.HasShareVisitor(share.Id, endpointHash)) return;
if (share.Settings.MaxVisitors > 0 && tx.CountShareVisitors(share.Id) >= share.Settings.MaxVisitors)
@@ -360,6 +365,7 @@ public sealed class ShareService(
ItemId = i.ItemId.ToBase64Url(),
Bytes = i.Bytes,
Frames = i.FrameCount,
Complete = i.IsComplete,
MaxDownloads = i.MaxDownloads,
DownloadsLeft = i.MaxDownloads == 0 ? null : Math.Max(0, i.MaxDownloads - i.Downloads),
}).ToList(),
@@ -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);
+100 -40
View File
@@ -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(