using System.Buffers; using System.Diagnostics; using System.IO.Compression; 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, IFileGrants files) { 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) }, Files = new FilesInfoBody { Enabled = files.Enabled, Url = files.Enabled ? files.PublicUrl : null }, Choices = new SettingChoicesBody { MaxMembers = new[] { 2, 3, 5, 10, limits.MaxMembers }.Where(n => n >= 1 && n <= limits.MaxMembers).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, MaxWorkspaceBytes = limits.MaxWorkspaceBytes, MaxMembers = limits.MaxMembers, 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); MapFiles(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); string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString(); await captcha.VerifyAsync(token.Length > 0 ? token : null, ct); return Json(svc.CreateChannel(req, username), 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) => { // 空间头像(动图)比其他设置大得多,单独放宽请求体上限 if (ctx.Features.Get() is { IsReadOnly: false } size) size.MaxRequestBodySize = 4 * 1024 * 1024; var patch = await ReadJsonCompressed(ctx.Request, ApiJson.Default.ChannelSettingsPatch, 6 * 1024 * 1024, ct); var info = svc.UpdateSettings(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), patch); return Json(info, ApiJson.Default.ChannelInfo); }); // 空间头像(信息里只带版本):默认缩略图,size=full 原图。磁盘文件直接发送,带 ETag(内容哈希),没变时回 304 api.MapGet("/channels/{id}/avatar", (string id, HttpContext ctx, ChannelService svc) => { bool thumb = ctx.Request.Query["size"] != "full"; return AvatarResult(ctx, svc.GetChannelAvatarBlob(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), thumb)); }); 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}/join", 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: false), request.Password), ApiJson.Default.JoinResult); }); MapChannelAccess(api, accounts); // ───────── 帧与日志 ───────── 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: false); 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: false); 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: false); 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: false); 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: false); 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 value, JsonTypeInfo 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 InvitationBodies(IReadOnlyList 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) || Envelope.TryParseHeader(frame, out 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.TryParseHeader(frame, out 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); } /// /// 读 JSON 请求体,支持 Content-Encoding: gzip(头像这类带 base64 的大请求,压缩后能省下 base64 多出来的约四分之一)。 /// 解压后的大小另外限制,防止小体积的压缩炸弹。 /// private static async Task ReadJsonCompressed(HttpRequest req, JsonTypeInfo info, int maxBytes, CancellationToken ct) where T : class { try { Stream body = req.Body; if (string.Equals(req.Headers.ContentEncoding.ToString(), "gzip", StringComparison.OrdinalIgnoreCase)) body = new GZipStream(req.Body, CompressionMode.Decompress); using var plain = new MemoryStream(); var buffer = new byte[81920]; int read; while ((read = await body.ReadAsync(buffer, ct)) > 0) { if (plain.Length + read > maxBytes) throw new WebDropException(ErrorCode.BadRequest); plain.Write(buffer, 0, read); } plain.Position = 0; return await JsonSerializer.DeserializeAsync(plain, info, ct) ?? throw new WebDropException(ErrorCode.BadRequest); } catch (Exception error) when (error is JsonException or InvalidDataException) { throw new WebDropException(ErrorCode.BadRequest); } } /// 头像文件(带 ETag = 内容哈希,没变时 304)。直接按路径发送,不读进内存 private static IResult AvatarResult(HttpContext ctx, AvatarBlob? blob) { if (blob is null) return Results.NotFound(); var etag = $"\"{blob.Hash}\""; ctx.Response.Headers.ETag = etag; ctx.Response.Headers.CacheControl = "private, no-cache"; if (ctx.Request.Headers.IfNoneMatch == etag) return Results.StatusCode(StatusCodes.Status304NotModified); return Results.File(blob.Path, blob.Mime); } private static async Task ReadJson(HttpRequest req, JsonTypeInfo 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); } } /// 读取请求体,超过上限立即拒绝(先看 Content-Length,再按实际读取量)。 private static async Task ReadBody(HttpRequest req, int max, CancellationToken ct) { if (req.ContentLength is { } declared && declared > max) throw new WebDropException(ErrorCode.TooLarge); // 长度已知:直接读进刚好大小的数组,少一份拷贝(帧最大几 MB,多一份就是一次大对象分配) if (req.ContentLength is { } known) { var exact = new byte[(int)known]; int filled = 0; while (filled < exact.Length) { int got = await req.Body.ReadAsync(exact.AsMemory(filled), ct); if (got == 0) throw new WebDropException(ErrorCode.BadRequest); filled += got; } return exact; } using var ms = new MemoryStream(1024); byte[] buffer = ArrayPool.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.Shared.Return(buffer); } return ms.ToArray(); } private static async Task 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); } } /// 等待信号或超时。返回 true 表示信号先到。 private static async Task 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; } /// WebSocket 的下行由订阅循环负责,这里只负责读取以便察觉客户端关闭。 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(); } } }