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
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.<role>, wd.auth.<b64url>, wd.ep.<b64url>
|
||||
/// </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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -34,6 +34,7 @@ public enum FrameFlags : byte
|
||||
Snapshot = 2,
|
||||
Gated = 4,
|
||||
Enc = 8,
|
||||
Control = 16,
|
||||
}
|
||||
|
||||
public enum LogKind : byte
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
@@ -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