using System.Buffers; using System.Diagnostics; using System.Net.WebSockets; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using WebDrop.Core; namespace WebDrop.Api; public static 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) { 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 }, Limits = new LimitsBody { MaxPayloadBytes = limits.MaxPayloadBytes, MaxFramesPerItem = limits.MaxFramesPerItem, MaxChannelBytes = limits.MaxChannelBytes, MaxSeats = limits.MaxSeats, MaxTtlSeconds = limits.MaxTtlSeconds, }, }, ApiJson.Default.ServerInfoBody)); // ───────── 频道 ───────── api.MapPost("/channels", async (HttpContext ctx, ChannelService svc, CancellationToken ct) => { 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); }).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", (string id, HttpContext ctx, ChannelService svc) => Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true)), ApiJson.Default.SeatJoined)); 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.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, 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()); 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); 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); 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); } 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); 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) => { svc.DeleteItem(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), ParseId(itemId)); return Results.NoContent(); }); // ───────── 便捷分享(独立于频道,无席位和日志) ───────── api.MapPost("/shares", async (HttpContext ctx, ShareService svc, CancellationToken ct) => { 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); }).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 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 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); using var ms = new MemoryStream(req.ContentLength is { } len ? (int)len : 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(); } } }