587 lines
31 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|