Files
WebDrop/server/src/WebDrop.Api/Endpoints.cs
T

587 lines
31 KiB
C#

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;
using WebDrop.Storage;
namespace WebDrop.Api;
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, SqliteAccountStore accounts)
{
var api = app.MapGroup("/api/v1");
api.MapGet("/info", (ServerLimits limits) => Json(new ServerInfoBody
{
Captcha = new CaptchaBody
{
Provider = options.Captcha.Provider,
Enabled = captcha.Enabled,
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,
MaxFramesPerItem = limits.MaxFramesPerItem,
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);
// ───────── 频道 ─────────
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, 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) =>
Json(svc.GetChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false)), ApiJson.Default.ChannelInfo));
api.MapPatch("/channels/{id}/settings", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var patch = await ReadJson(ctx.Request, ApiJson.Default.ChannelSettingsPatch, ct);
var info = svc.UpdateSettings(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), patch);
return Json(info, ApiJson.Default.ChannelInfo);
});
api.MapDelete("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) =>
{
svc.DeleteChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false));
return Results.NoContent();
});
// ───────── 席位 ─────────
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) =>
{
var seats = svc.ListSeats(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false));
return Json([.. seats], ApiJson.Default.ListSeatInfo);
});
api.MapPut("/channels/{id}/seats/me", async (string id, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) =>
{
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
byte[] label = await ReadBody(ctx.Request, limits.MaxSeatLabelBytes, ct);
svc.SetSeatLabel(ParseId(id), creds, label);
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);
svc.ReleaseSeat(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash);
return Results.NoContent();
});
// ───────── 帧与日志 ─────────
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 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);
});
api.MapGet("/channels/{id}/log", async (string id, HttpContext ctx, ChannelService svc, ChannelHub hub, CancellationToken ct) =>
{
var channelId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
long from = QueryLong(ctx.Request, "from", 0);
int limit = (int)Math.Clamp(QueryLong(ctx.Request, "limit", 200), 1, 1000);
var wait = TimeSpan.FromSeconds(Math.Clamp(QueryLong(ctx.Request, "wait", 0), 0, 25));
var page = await ReadLogWithWait(svc, hub, channelId, creds, from, limit, wait, ct);
ctx.Response.Headers[LastSeqHeader] = page.LastSeq.ToString();
var all = new byte[page.Records.Sum(r => r.Bytes.Length)];
int offset = 0;
foreach (var r in page.Records)
{
r.Bytes.CopyTo(all, offset);
offset += r.Bytes.Length;
}
return Results.Bytes(all, OctetStream);
});
api.MapGet("/channels/{id}/ws", async (string id, HttpContext ctx, ChannelService svc, ChannelHub hub) =>
{
if (!ctx.WebSockets.IsWebSocketRequest || !ctx.WebSockets.WebSocketRequestedProtocols.Contains("wd.v1"))
throw new WebDropException(ErrorCode.BadRequest);
var channelId = ParseId(id);
var creds = RequestAuth.FromSubprotocols(ctx.WebSockets.WebSocketRequestedProtocols, accounts);
long from = QueryLong(ctx.Request, "from", 0);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted);
WebSocket? ws = null;
try
{
await LogStreamer.RunAsync(svc, hub, channelId, creds, from, async () =>
{
ws = await ctx.WebSockets.AcceptWebSocketAsync("wd.v1");
_ = WatchClose(ws, cts);
return new WebSocketSink(ws);
}, cts.Token, () => StreamAccountValid(ctx, accounts, creds));
}
finally
{
await CloseQuietly(ws);
}
return Results.Empty;
});
api.MapGet("/channels/{id}/events", async (string id, HttpContext ctx, ChannelService svc, ChannelHub hub) =>
{
var channelId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
long from = QueryLong(ctx.Request, "from", -1);
if (from < 0)
from = long.TryParse(ctx.Request.Headers["Last-Event-ID"].ToString(), out long last) && last >= 0 ? last + 1 : 0;
await LogStreamer.RunAsync(svc, hub, channelId, creds, from, async () =>
{
ctx.Response.ContentType = "text/event-stream";
ctx.Response.Headers["X-Accel-Buffering"] = "no";
await ctx.Response.StartAsync();
return new SseSink(ctx.Response);
}, ctx.RequestAborted, () => StreamAccountValid(ctx, accounts, creds));
return Results.Empty;
});
// ───────── 条目 ─────────
api.MapGet("/channels/{id}/items/{itemId}/frames", async (string id, string itemId, HttpContext ctx, ChannelService svc, ChannelHub hub, CancellationToken ct) =>
{
var channelId = ParseId(id);
var item = ParseId(itemId);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
long from = QueryLong(ctx.Request, "from", 0);
if (from is < 0 or > uint.MaxValue) throw new WebDropException(ErrorCode.BadRequest);
svc.StartItemDownload(channelId, creds, item, (uint)from);
ctx.Response.ContentType = OctetStream;
using var sub = hub.Subscribe(channelId);
uint next = (uint)from;
while (!ct.IsCancellationRequested)
{
var changed = sub.Changed;
var chunk = await svc.ReadItemFramesAsync(channelId, creds, item, next, 16, ct);
foreach (var frame in chunk.Frames) await ctx.Response.Body.WriteAsync(frame, ct);
next = chunk.NextIndex;
if (chunk.Complete) break;
if (chunk.Frames.Count > 0) continue;
// 条目还在上传:等新帧;长时间没有进展就结束本次响应,客户端用 from=next 续传(不重复计数)
if (!await WaitOrTimeout(changed, MaxLongPoll, ct)) break;
}
return Results.Empty;
});
api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) =>
{
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();
});
// ───────── 便捷分享(独立于频道,无席位和日志) ─────────
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, username), ApiJson.Default.ShareInfo, StatusCodes.Status201Created);
}).RequireRateLimiting(CreatePolicy);
api.MapPatch("/shares/{id}/settings", async (string id, HttpContext ctx, ShareService svc, CancellationToken ct) =>
{
var patch = await ReadJson(ctx.Request, ApiJson.Default.ShareSettingsPatch, ct);
return Json(svc.UpdateSettings(ParseId(id), RequestAuth.ShareFromHeaders(ctx.Request, requireEndpoint: false), patch), ApiJson.Default.ShareInfo);
});
api.MapPost("/shares/{id}/frames", async (string id, HttpContext ctx, ShareService svc, ServerLimits limits, CancellationToken ct) =>
{
var creds = RequestAuth.ShareFromHeaders(ctx.Request, requireEndpoint: false);
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(ParseId(id), creds, body, maxDownloads, ct);
return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK);
});
api.MapDelete("/shares/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ShareService svc) =>
{
svc.DeleteItem(ParseId(id), RequestAuth.ShareFromHeaders(ctx.Request, requireEndpoint: false), ParseId(itemId));
return Results.NoContent();
});
api.MapDelete("/shares/{id}", (string id, HttpContext ctx, ShareService svc) =>
{
svc.DeleteDraft(ParseId(id), RequestAuth.ShareFromHeaders(ctx.Request, requireEndpoint: false));
return Results.NoContent();
});
api.MapPost("/shares/{id}/finish", async (string id, HttpContext ctx, ShareService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.FinishShareRequest, ct);
return Json(svc.Finish(ParseId(id), RequestAuth.ShareFromHeaders(ctx.Request, requireEndpoint: false), req.ManifestItemId), ApiJson.Default.ShareInfo);
});
api.MapGet("/shares/{id}", (string id, HttpContext ctx, ShareService svc) =>
Json(svc.GetInfo(ParseId(id), RequestAuth.ShareFromHeaders(ctx.Request, requireEndpoint: false)), ApiJson.Default.ShareInfo));
api.MapGet("/shares/{id}/manifest", async (string id, HttpContext ctx, ShareService svc) =>
{
byte[] manifest = await svc.ReadManifestAsync(ParseId(id), RequestAuth.ShareFromHeaders(ctx.Request, requireEndpoint: true));
return Results.Bytes(manifest, OctetStream);
});
api.MapGet("/shares/{id}/items/{itemId}/frames", async (string id, string itemId, HttpContext ctx, ShareService svc, CancellationToken ct) =>
{
var shareId = ParseId(id);
var parsedItemId = ParseId(itemId);
var creds = RequestAuth.ShareFromHeaders(ctx.Request, requireEndpoint: true);
long from = QueryLong(ctx.Request, "from", 0);
if (from is < 0 or > uint.MaxValue) throw new WebDropException(ErrorCode.BadRequest);
uint next = (uint)from;
svc.StartItemDownload(shareId, creds, parsedItemId, next);
ctx.Response.ContentType = OctetStream;
while (!ct.IsCancellationRequested)
{
var chunk = await svc.ReadItemFramesAsync(shareId, creds, parsedItemId, next, ct);
foreach (var frame in chunk.Frames) await ctx.Response.Body.WriteAsync(frame, ct);
next = chunk.NextIndex;
if (chunk.Complete) break;
if (chunk.Frames.Count == 0) throw new WebDropException(ErrorCode.Gone);
}
return Results.Empty;
});
}
// ───────── 辅助 ─────────
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 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);
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);
private static long QueryLong(HttpRequest req, string name, long fallback)
{
string value = req.Query[name].ToString();
if (value.Length == 0) return fallback;
return long.TryParse(value, out long n) ? n : throw new WebDropException(ErrorCode.BadRequest);
}
private static long? ParseMaxDownloads(string header)
{
if (header.Length == 0) return null;
return long.TryParse(header, out long n) && n >= 0 ? n : throw new WebDropException(ErrorCode.BadRequest);
}
private static async Task<T> ReadJson<T>(HttpRequest req, JsonTypeInfo<T> info, CancellationToken ct) where T : class
{
try
{
return await JsonSerializer.DeserializeAsync(req.Body, info, ct) ?? throw new WebDropException(ErrorCode.BadRequest);
}
catch (JsonException)
{
throw new WebDropException(ErrorCode.BadRequest);
}
}
/// <summary>读取请求体,超过上限立即拒绝(先看 Content-Length,再按实际读取量)。</summary>
private static async Task<byte[]> ReadBody(HttpRequest req, int max, CancellationToken ct)
{
if (req.ContentLength is { } declared && declared > max) throw new WebDropException(ErrorCode.TooLarge);
using var ms = new MemoryStream(req.ContentLength is { } len ? (int)len : 1024);
byte[] buffer = ArrayPool<byte>.Shared.Rent(16 * 1024);
try
{
int n;
while ((n = await req.Body.ReadAsync(buffer, ct)) > 0)
{
if (ms.Length + n > max) throw new WebDropException(ErrorCode.TooLarge);
ms.Write(buffer, 0, n);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
return ms.ToArray();
}
private static async Task<LogPage> ReadLogWithWait(
ChannelService svc, ChannelHub hub, Id16 id, Credentials creds, long from, int limit, TimeSpan wait, CancellationToken ct)
{
if (wait <= TimeSpan.Zero) return await svc.ReadLogAsync(id, creds, from, limit, ct);
using var sub = hub.Subscribe(id);
var clock = Stopwatch.StartNew();
while (true)
{
var changed = sub.Changed;
var page = await svc.ReadLogAsync(id, creds, from, limit, ct);
var remaining = wait - clock.Elapsed;
if (page.Records.Count > 0 || remaining <= TimeSpan.Zero) return page;
await WaitOrTimeout(changed, remaining, ct);
}
}
/// <summary>等待信号或超时。返回 true 表示信号先到。</summary>
private static async Task<bool> WaitOrTimeout(Task changed, TimeSpan timeout, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var delay = Task.Delay(timeout, cts.Token);
var winner = await Task.WhenAny(changed, delay);
await cts.CancelAsync();
ct.ThrowIfCancellationRequested();
return winner == changed;
}
/// <summary>WebSocket 的下行由订阅循环负责,这里只负责读取以便察觉客户端关闭。</summary>
private static async Task WatchClose(WebSocket ws, CancellationTokenSource cts)
{
var buffer = new byte[256];
try
{
while (ws.State == WebSocketState.Open)
{
var result = await ws.ReceiveAsync(buffer.AsMemory(), cts.Token);
if (result.MessageType == WebSocketMessageType.Close) break;
}
}
catch (Exception)
{
// 连接异常中断等同于关闭
}
finally
{
try { await cts.CancelAsync(); }
catch (ObjectDisposedException) { }
}
}
private static async Task CloseQuietly(WebSocket? ws)
{
if (ws is null) return;
try
{
if (ws.State is WebSocketState.Open or WebSocketState.CloseReceived)
await ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None);
}
catch (Exception)
{
// 对端已断开
}
finally
{
ws.Dispose();
}
}
}