feat(api): add account workspaces and signed content events

This commit is contained in:
justme committed 2026-10-14 04:05:30 +08:00
1 parent 937b774a93
commit 7b8d61d1ba
63 files changed
+5407 -370

No files matched your search

+102 -4
View File
@@ -1,6 +1,7 @@
using System.Buffers;
using System.Diagnostics;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using WebDrop.Core;
@@ -28,6 +29,14 @@ public static partial class Endpoints
ApiEndpoint = captcha.Enabled ? options.Captcha.ApiEndpoint : null,
},
Transports = new TransportsBody { Http3 = options.EnableHttp3 },
Defaults = new DefaultsBody { Channel = CreationDefaults.Channel(limits), Share = CreationDefaults.Share(limits) },
Choices = new SettingChoicesBody
{
Seats = new[] { 2, 3, 5, 10, limits.MaxSeats }.Where(n => n >= 1 && n <= limits.MaxSeats).Distinct().Order().ToArray(),
TtlSeconds = new[] { 3600, 86_400, 604_800, limits.MinTtlSeconds, limits.MaxTtlSeconds, CreationDefaults.Channel(limits).TtlSeconds }
.Where(n => n >= limits.MinTtlSeconds && n <= limits.MaxTtlSeconds).Distinct().Order().ToArray(),
MaxVisitors = new[] { 0, 2, 5, 10, 20, limits.MaxVisitors }.Where(n => n <= limits.MaxVisitors).Distinct().Order().ToArray(),
},
Limits = new LimitsBody
{
MaxPayloadBytes = limits.MaxPayloadBytes,
@@ -35,10 +44,16 @@ public static partial class Endpoints
MaxChannelBytes = limits.MaxChannelBytes,
MaxSeats = limits.MaxSeats,
MaxTtlSeconds = limits.MaxTtlSeconds,
MinTtlSeconds = limits.MinTtlSeconds,
MaxItems = limits.MaxItemsLimit,
MaxVisitors = limits.MaxVisitors,
},
}, ApiJson.Default.ServerInfoBody));
MapAccounts(api, accounts);
MapPasskeys(api, accounts);
MapWorkspace(api, accounts);
MapWorkspaceCrypto(api);
// ───────── 频道 ─────────
@@ -109,14 +124,15 @@ public static partial class Endpoints
// ───────── 帧与日志 ─────────
api.MapPost("/channels/{id}/frames", async (string id, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) =>
api.MapPost("/channels/{id}/frames", async (string id, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore, ServerLimits limits, CancellationToken ct) =>
{
var channelId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString());
byte[] body = await ReadBody(ctx.Request, Envelope.HeaderSize + limits.MaxPayloadBytes, ct);
var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, ct);
var signedEvent = ParseSignedContentEvent(ctx.Request, accountStore, channelId, creds, body);
var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, ct, signedEvent);
return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK);
});
@@ -217,9 +233,12 @@ public static partial class Endpoints
return Results.Empty;
});
api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc) =>
api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) =>
{
svc.DeleteItem(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), ParseId(itemId));
var channelId = ParseId(id); var parsedItemId = ParseId(itemId);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
var proof = ParseSignedDeleteEvent(ctx.Request, accountStore, svc, channelId, parsedItemId, creds);
svc.DeleteItem(channelId, creds, parsedItemId, proof);
return Results.NoContent();
});
@@ -355,6 +374,85 @@ public static partial class Endpoints
catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException) { throw new WebDropException(ErrorCode.BadRequest); }
}
private static SignedContentEventProof? ParseSignedContentEvent(HttpRequest request, SqliteAccountStore accounts,
Id16 spaceId, Credentials creds, byte[] frame)
{
string[] names = ["X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain",
"X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature", "X-WD-Event-Target"];
var values = names.Select(name => request.Headers[name].ToString()).ToArray();
bool any = values.Any(value => value.Length > 0);
if (!any)
{
if (Envelope.TryParse(frame, out var legacyEnvelope) && legacyEnvelope.IsFinal && creds.AccountName is not null
&& creds.AccountDeviceId is not null && accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId) is not null)
throw new WebDropException(ErrorCode.Forbidden);
return null;
}
if (!Envelope.TryParse(frame, out var envelope) || !envelope.IsFinal || creds.AccountName is null || creds.AccountDeviceId is null
|| values.Take(8).Any(string.IsNullOrEmpty)) throw new WebDropException(ErrorCode.BadRequest);
if (!Guid.TryParseExact(values[2], "D", out _) || values[3] is not ("chat" or "files")
|| !int.TryParse(values[4], out var epoch) || epoch < 0
|| !int.TryParse(values[5], out var frameCount) || frameCount < 1 || frameCount > 65_536)
throw new WebDropException(ErrorCode.BadRequest);
var domain = values[3] == "chat" ? CryptoDomain.Chat : CryptoDomain.Files;
var eventKind = values[1];
if (domain == CryptoDomain.Chat && !eventKind.StartsWith("chat.", StringComparison.Ordinal)
|| domain == CryptoDomain.Files && !eventKind.StartsWith("file.", StringComparison.Ordinal)) throw new WebDropException(ErrorCode.BadRequest);
if (!Codec.TryDecodeExact(values[0], 16, out var eventId)
|| !Codec.TryDecodeExact(values[6], 32, out var manifestHash)
|| !Codec.TryDecodeExact(values[7], 64, out var signature)) throw new WebDropException(ErrorCode.BadRequest);
byte[] target = [];
if (values[8].Length > 0 && !Codec.TryDecodeExact(values[8], 16, out target)) throw new WebDropException(ErrorCode.BadRequest);
var deviceKey = accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId)
?? throw new WebDropException(ErrorCode.Forbidden);
var rootPublicKey = accounts.GetSigningPublicKey(creds.AccountName) ?? throw new WebDropException(ErrorCode.Forbidden);
using var publicKey = JsonDocument.Parse(deviceKey.PublicKey);
var jwk = publicKey.RootElement;
if (!Codec.TryDecodeExact(jwk.GetProperty("x").GetString(), 32, out var x)
|| !Codec.TryDecodeExact(jwk.GetProperty("y").GetString(), 32, out var y)) throw new WebDropException(ErrorCode.BadRequest);
var payload = SignatureCodec.ContentEventPayload(spaceId.ToArray(), values[2], creds.AccountName,
creds.AccountDeviceId, eventId, eventKind, domain, epoch, envelope.ItemId.ToArray(), frameCount, manifestHash, target);
if (!SignatureCodec.VerifyP256(x, y, "content.event", payload, signature)) throw new WebDropException(ErrorCode.Forbidden);
return new SignedContentEventProof(eventId, eventKind, creds.AccountName, values[2], domain, epoch, frameCount,
manifestHash, creds.AccountDeviceId, signature, values[8].Length == 0 ? null : values[8],
rootPublicKey, deviceKey.PublicKey, deviceKey.CertificateSignature);
}
private static SignedContentEventProof? ParseSignedDeleteEvent(HttpRequest request, SqliteAccountStore accounts,
ChannelService service, Id16 spaceId, Id16 itemId, Credentials creds)
{
string[] names = ["X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain",
"X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature"];
var values = names.Select(name => request.Headers[name].ToString()).ToArray();
if (values.All(string.IsNullOrEmpty)) return null;
if (creds.AccountName is null || creds.AccountDeviceId is null || values.Any(string.IsNullOrEmpty)) throw new WebDropException(ErrorCode.BadRequest);
var obj = service.ObjectSigningContext(spaceId, creds, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
var createdEvent = obj.Event ?? throw new WebDropException(ErrorCode.BadRequest);
if (!Guid.TryParseExact(values[2], "D", out _) || values[3] is not ("chat" or "files")
|| !int.TryParse(values[4], out var epoch) || !int.TryParse(values[5], out var frameCount)) throw new WebDropException(ErrorCode.BadRequest);
var domain = values[3] == "chat" ? CryptoDomain.Chat : CryptoDomain.Files;
var kind = domain == CryptoDomain.Chat ? "chat.delete" : "file.delete";
if (values[1] != kind || obj.ChannelId != values[2] || obj.Domain != domain || obj.Epoch != epoch
|| createdEvent.FrameCount != frameCount
|| !Codec.TryDecodeExact(values[0], 16, out var eventId)
|| !Codec.TryDecodeExact(values[6], 32, out var manifest)
|| !Codec.TryDecodeExact(values[7], 64, out var signature)
|| !Codec.TryDecodeExact(createdEvent.ManifestHash, 32, out var objectManifest)
|| !CryptographicOperations.FixedTimeEquals(manifest, objectManifest)) throw new WebDropException(ErrorCode.BadRequest);
var deviceKey = accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId) ?? throw new WebDropException(ErrorCode.Forbidden);
using var publicKey = JsonDocument.Parse(deviceKey.PublicKey); var jwk = publicKey.RootElement;
if (!Codec.TryDecodeExact(jwk.GetProperty("x").GetString(), 32, out var x)
|| !Codec.TryDecodeExact(jwk.GetProperty("y").GetString(), 32, out var y)) throw new WebDropException(ErrorCode.BadRequest);
var payload = SignatureCodec.ContentEventPayload(spaceId.ToArray(), obj.ChannelId, creds.AccountName,
creds.AccountDeviceId, eventId, kind, domain, epoch, itemId.ToArray(), frameCount, manifest, []);
if (!SignatureCodec.VerifyP256(x, y, "content.event", payload, signature)) throw new WebDropException(ErrorCode.Forbidden);
return new SignedContentEventProof(eventId, kind, creds.AccountName, obj.ChannelId, domain, epoch, frameCount,
manifest, creds.AccountDeviceId, signature, null, accounts.GetSigningPublicKey(creds.AccountName)!,
deviceKey.PublicKey, deviceKey.CertificateSignature);
}
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);