feat(server): implement sealed share lifecycle
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
1 parent
e02e024605
commit
42b93ae2d8
45 files changed
+6327
No files matched your search
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public sealed class ErrorBody
|
||||
{
|
||||
[JsonPropertyName("error")] public required string Error { get; init; }
|
||||
}
|
||||
|
||||
public sealed class GoneLogBody
|
||||
{
|
||||
[JsonPropertyName("error")] public string Error { get; init; } = "gone";
|
||||
[JsonPropertyName("oldest_seq")] public required long OldestSeq { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ServerInfoBody
|
||||
{
|
||||
[JsonPropertyName("protocol")] public int Protocol { get; init; } = 1;
|
||||
[JsonPropertyName("captcha")] public required CaptchaBody Captcha { get; init; }
|
||||
[JsonPropertyName("transports")] public required TransportsBody Transports { get; init; }
|
||||
[JsonPropertyName("limits")] public required LimitsBody Limits { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>告诉前端要不要出示验证码、widget 该连哪里(Cap 的 data-cap-api-endpoint)。</summary>
|
||||
public sealed class CaptchaBody
|
||||
{
|
||||
[JsonPropertyName("provider")] public required string Provider { get; init; }
|
||||
[JsonPropertyName("enabled")] public required bool Enabled { get; init; }
|
||||
[JsonPropertyName("api_endpoint")] public string? ApiEndpoint { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TransportsBody
|
||||
{
|
||||
[JsonPropertyName("websocket")] public bool WebSocket { get; init; } = true;
|
||||
[JsonPropertyName("sse")] public bool Sse { get; init; } = true;
|
||||
[JsonPropertyName("http3")] public required bool Http3 { get; init; }
|
||||
}
|
||||
|
||||
public sealed class LimitsBody
|
||||
{
|
||||
[JsonPropertyName("max_payload_bytes")] public required int MaxPayloadBytes { get; init; }
|
||||
[JsonPropertyName("max_frames_per_item")] public required int MaxFramesPerItem { get; init; }
|
||||
[JsonPropertyName("max_channel_bytes")] public required long MaxChannelBytes { get; init; }
|
||||
[JsonPropertyName("max_seats")] public required int MaxSeats { get; init; }
|
||||
[JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; }
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(ErrorBody))]
|
||||
[JsonSerializable(typeof(GoneLogBody))]
|
||||
[JsonSerializable(typeof(ServerInfoBody))]
|
||||
[JsonSerializable(typeof(ChannelInfo))]
|
||||
[JsonSerializable(typeof(ShareInfo))]
|
||||
[JsonSerializable(typeof(CreateShareRequest))]
|
||||
[JsonSerializable(typeof(ShareSettingsPatch))]
|
||||
[JsonSerializable(typeof(FinishShareRequest))]
|
||||
[JsonSerializable(typeof(CreateChannelRequest))]
|
||||
[JsonSerializable(typeof(ChannelSettingsPatch))]
|
||||
[JsonSerializable(typeof(SeatJoined))]
|
||||
[JsonSerializable(typeof(List<SeatInfo>))]
|
||||
[JsonSerializable(typeof(AppendResult))]
|
||||
[JsonSerializable(typeof(SiteverifyRequest))]
|
||||
[JsonSerializable(typeof(SiteverifyResponse))]
|
||||
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public sealed partial class ApiJson : JsonSerializerContext;
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public sealed class SiteverifyRequest
|
||||
{
|
||||
[JsonPropertyName("secret")] public required string Secret { get; init; }
|
||||
[JsonPropertyName("response")] public required string Response { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SiteverifyResponse
|
||||
{
|
||||
[JsonPropertyName("success")] public bool Success { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用 Cap(https://github.com/tiagozip/cap)校验创建频道的验证码。
|
||||
/// 工作量证明的出题、求解、验证全部由 Cap 完成,本项目不实现任何 PoW。
|
||||
///
|
||||
/// 流程:浏览器上的 Cap widget 直接向 Cap 取挑战、求解、换得 token,随创建请求放进 X-WD-Cap;
|
||||
/// 这里只做一件事:把 token 交给 Cap 的 siteverify(与 reCAPTCHA / hCaptcha 兼容的接口)确认。
|
||||
/// </summary>
|
||||
public sealed class CapCaptchaVerifier(HttpClient http, CaptchaOptions options, ILogger<CapCaptchaVerifier> logger) : ICaptchaVerifier
|
||||
{
|
||||
// token 来自客户端,先限制长度,避免把任意大的内容转发给第三方
|
||||
private const int MaxTokenLength = 2048;
|
||||
|
||||
public bool Enabled => true;
|
||||
|
||||
public async Task VerifyAsync(string? token, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token) || token.Length > MaxTokenLength)
|
||||
throw new WebDropException(ErrorCode.CaptchaRequired);
|
||||
|
||||
var body = JsonSerializer.Serialize(
|
||||
new SiteverifyRequest { Secret = options.Secret, Response = token }, ApiJson.Default.SiteverifyRequest);
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, options.SiteverifyUrl)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
using var response = await http.SendAsync(request, ct).ConfigureAwait(false);
|
||||
|
||||
// 非 2xx(站点密钥写错、Cap 出错等)是我们这边的配置或依赖问题,不是用户没通过验证
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogError("Cap siteverify 返回了 {Status}", (int)response.StatusCode);
|
||||
throw new WebDropException(ErrorCode.CaptchaUnavailable);
|
||||
}
|
||||
|
||||
var result = await JsonSerializer.DeserializeAsync(
|
||||
await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false),
|
||||
ApiJson.Default.SiteverifyResponse, ct).ConfigureAwait(false);
|
||||
|
||||
if (result is not { Success: true }) throw new WebDropException(ErrorCode.CaptchaRequired);
|
||||
}
|
||||
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException
|
||||
&& !ct.IsCancellationRequested)
|
||||
{
|
||||
// 只记录异常类型:不记录 token、密钥或响应内容
|
||||
logger.LogError("Cap siteverify 调用失败:{Type}", e.GetType().Name);
|
||||
throw new WebDropException(ErrorCode.CaptchaUnavailable);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 进程内的订阅枢纽。只传递「有变化」的信号,订阅者被唤醒后自己从日志里按游标读取,
|
||||
/// 所以不存在回放与实时之间漏帧的竞态,也不需要为慢客户端缓冲数据。
|
||||
/// 因此只支持单实例部署(见 architecture.md)。
|
||||
/// </summary>
|
||||
public sealed class ChannelHub : IChannelEvents
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<Id16, Signal> _signals = [];
|
||||
|
||||
public Subscription Subscribe(Id16 id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_signals.TryGetValue(id, out var signal)) _signals[id] = signal = new Signal();
|
||||
signal.Refs++;
|
||||
return new Subscription(this, id, signal);
|
||||
}
|
||||
}
|
||||
|
||||
public void Advanced(Id16 channelId, long seq) => Publish(channelId, s => s.Seq = Math.Max(s.Seq, seq));
|
||||
public void SettingsChanged(Id16 channelId) => Publish(channelId, s => s.SettingsVersion++);
|
||||
public void SeatsChanged(Id16 channelId) => Publish(channelId, s => s.SeatsVersion++);
|
||||
public void ChannelDeleted(Id16 channelId) => Publish(channelId, s => s.Deleted = true);
|
||||
|
||||
private void Publish(Id16 id, Action<Signal> mutate)
|
||||
{
|
||||
TaskCompletionSource? toComplete;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_signals.TryGetValue(id, out var signal)) return; // 没有订阅者,无需通知
|
||||
mutate(signal);
|
||||
toComplete = signal.Replace();
|
||||
}
|
||||
toComplete.TrySetResult(); // 在锁外完成,避免续体在锁内运行
|
||||
}
|
||||
|
||||
private void Release(Id16 id, Signal signal)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (--signal.Refs == 0) _signals.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Signal
|
||||
{
|
||||
private TaskCompletionSource _tcs = New();
|
||||
public int Refs;
|
||||
public long Seq;
|
||||
public int SettingsVersion;
|
||||
public int SeatsVersion;
|
||||
public bool Deleted;
|
||||
|
||||
public Task Changed => _tcs.Task;
|
||||
|
||||
public TaskCompletionSource Replace()
|
||||
{
|
||||
var old = _tcs;
|
||||
_tcs = New();
|
||||
return old;
|
||||
}
|
||||
|
||||
private static TaskCompletionSource New() => new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用法:先取 <see cref="Changed"/>,再读状态,最后等待取到的那个任务。
|
||||
/// 取任务之后发生的任何发布都会让它完成,不会丢通知。
|
||||
/// </summary>
|
||||
public sealed class Subscription : IDisposable
|
||||
{
|
||||
private readonly ChannelHub _hub;
|
||||
private readonly Id16 _id;
|
||||
private readonly Signal _signal;
|
||||
private int _disposed;
|
||||
|
||||
internal Subscription(ChannelHub hub, Id16 id, Signal signal)
|
||||
{
|
||||
_hub = hub;
|
||||
_id = id;
|
||||
_signal = signal;
|
||||
}
|
||||
|
||||
public Task Changed
|
||||
{
|
||||
get { lock (_hub._gate) return _signal.Changed; }
|
||||
}
|
||||
|
||||
public (int Settings, int Seats, bool Deleted) State
|
||||
{
|
||||
get { lock (_hub._gate) return (_signal.SettingsVersion, _signal.SeatsVersion, _signal.Deleted); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0) _hub.Release(_id, _signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
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>(T value, JsonTypeInfo<T> 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<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
/// <summary>订阅的下行通道。WebSocket 与 SSE 各自实现,订阅循环共用。</summary>
|
||||
public interface ILogSink
|
||||
{
|
||||
ValueTask SendRecordAsync(LogRecord record, CancellationToken ct);
|
||||
ValueTask SendControlAsync(string json, CancellationToken ct);
|
||||
|
||||
/// <summary>流已建立之后出现的业务错误(如席位被释放),通知客户端并结束。</summary>
|
||||
ValueTask FailAsync(ErrorCode code, CancellationToken ct);
|
||||
}
|
||||
|
||||
public static class LogStreamer
|
||||
{
|
||||
private const int PageSize = 200;
|
||||
public static readonly TimeSpan Heartbeat = TimeSpan.FromSeconds(20);
|
||||
|
||||
/// <summary>
|
||||
/// 按游标持续读取日志并推送。<paramref name="open"/> 在首次鉴权和读取成功之后才被调用,
|
||||
/// 因此鉴权失败仍可以返回普通的 HTTP 错误,而不是先升级再断开。
|
||||
/// </summary>
|
||||
public static async Task RunAsync(
|
||||
ChannelService svc, ChannelHub hub, Id16 id, Credentials creds, long from,
|
||||
Func<Task<ILogSink>> open, CancellationToken ct)
|
||||
{
|
||||
using var sub = hub.Subscribe(id);
|
||||
ILogSink? sink = null;
|
||||
long cursor = from;
|
||||
var (settingsV, seatsV, _) = sub.State;
|
||||
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var changed = sub.Changed; // 先取任务再读状态:读取期间的发布不会丢
|
||||
var page = await svc.ReadLogAsync(id, creds, cursor, PageSize, ct).ConfigureAwait(false);
|
||||
sink ??= await open().ConfigureAwait(false);
|
||||
|
||||
foreach (var record in page.Records)
|
||||
{
|
||||
await sink.SendRecordAsync(record, ct).ConfigureAwait(false);
|
||||
cursor = record.Seq + 1;
|
||||
}
|
||||
|
||||
var (settingsNow, seatsNow, deleted) = sub.State;
|
||||
if (settingsNow != settingsV)
|
||||
{
|
||||
settingsV = settingsNow;
|
||||
await sink.SendControlAsync("""{"t":"settings"}""", ct).ConfigureAwait(false);
|
||||
}
|
||||
if (seatsNow != seatsV)
|
||||
{
|
||||
seatsV = seatsNow;
|
||||
await sink.SendControlAsync("""{"t":"seats"}""", ct).ConfigureAwait(false);
|
||||
}
|
||||
if (deleted) throw new WebDropException(ErrorCode.NotFound);
|
||||
|
||||
if (page.Records.Count >= PageSize) continue; // 还有积压,不用等
|
||||
|
||||
using var delayCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
var delay = Task.Delay(Heartbeat, delayCts.Token);
|
||||
var winner = await Task.WhenAny(changed, delay).ConfigureAwait(false);
|
||||
await delayCts.CancelAsync().ConfigureAwait(false);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (winner == delay)
|
||||
await sink.SendControlAsync($$"""{"t":"hb","seq":{{page.LastSeq}}}""", ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// 客户端断开,正常结束
|
||||
}
|
||||
catch (WebDropException e) when (sink is not null)
|
||||
{
|
||||
await sink.FailAsync(e.Code, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WebSocketSink(WebSocket ws) : ILogSink
|
||||
{
|
||||
public ValueTask SendRecordAsync(LogRecord record, CancellationToken ct) =>
|
||||
ws.SendAsync(record.Bytes.AsMemory(), WebSocketMessageType.Binary, true, ct);
|
||||
|
||||
public ValueTask SendControlAsync(string json, CancellationToken ct) =>
|
||||
ws.SendAsync(Encoding.UTF8.GetBytes(json).AsMemory(), WebSocketMessageType.Text, true, ct);
|
||||
|
||||
public async ValueTask FailAsync(ErrorCode code, CancellationToken ct)
|
||||
{
|
||||
if (ws.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
||||
await ws.CloseAsync(WebSocketCloseStatus.PolicyViolation, code.ToWire(), ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SseSink(HttpResponse response) : ILogSink
|
||||
{
|
||||
public async ValueTask SendRecordAsync(LogRecord record, CancellationToken ct)
|
||||
{
|
||||
await response.WriteAsync($"id: {record.Seq}\nevent: log\ndata: {Codec.Encode(record.Bytes)}\n\n", ct).ConfigureAwait(false);
|
||||
await response.Body.FlushAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask SendControlAsync(string json, CancellationToken ct)
|
||||
{
|
||||
await response.WriteAsync($"event: control\ndata: {json}\n\n", ct).ConfigureAwait(false);
|
||||
await response.Body.FlushAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask FailAsync(ErrorCode code, CancellationToken ct)
|
||||
{
|
||||
await response.WriteAsync($"event: error\ndata: {{\"error\":\"{code.ToWire()}\"}}\n\n", ct).ConfigureAwait(false);
|
||||
await response.Body.FlushAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
using WebDrop.Api;
|
||||
|
||||
var app = WebDropApp.Build(args);
|
||||
app.Run();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5024",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7212;http://localhost:5024",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
/// <summary>从请求头(或 WebSocket 子协议)解析凭据。这里只做格式校验,令牌是否正确由服务判断。</summary>
|
||||
public static class RequestAuth
|
||||
{
|
||||
public const string RoleHeader = "X-WD-Role";
|
||||
public const string EndpointHeader = "X-WD-Endpoint";
|
||||
public const string CaptchaHeader = "X-WD-Cap";
|
||||
|
||||
public static Credentials FromHeaders(HttpRequest req, bool requireEndpoint)
|
||||
{
|
||||
var role = ParseRole(req.Headers[RoleHeader].ToString());
|
||||
|
||||
byte[]? token = null;
|
||||
string auth = req.Headers.Authorization.ToString();
|
||||
if (auth.Length > 0)
|
||||
{
|
||||
const string prefix = "Bearer ";
|
||||
if (!auth.StartsWith(prefix, StringComparison.Ordinal)
|
||||
|| !Codec.TryDecodeExact(auth[prefix.Length..], TokenHash.Size, out token))
|
||||
throw new WebDropException(ErrorCode.BadRequest);
|
||||
}
|
||||
|
||||
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 浏览器的 WebSocket 不能设请求头,凭据放在子协议里:
|
||||
/// wd.v1, wd.role.<role>, wd.auth.<b64url>, wd.ep.<b64url>
|
||||
/// </summary>
|
||||
public static Credentials FromSubprotocols(IEnumerable<string> protocols)
|
||||
{
|
||||
string? role = null, auth = null, endpoint = null;
|
||||
foreach (string p in protocols)
|
||||
{
|
||||
if (p.StartsWith("wd.role.", StringComparison.Ordinal)) role = p["wd.role.".Length..];
|
||||
else if (p.StartsWith("wd.auth.", StringComparison.Ordinal)) auth = p["wd.auth.".Length..];
|
||||
else if (p.StartsWith("wd.ep.", StringComparison.Ordinal)) endpoint = p["wd.ep.".Length..];
|
||||
}
|
||||
|
||||
byte[]? token = null;
|
||||
if (auth is not null && !Codec.TryDecodeExact(auth, TokenHash.Size, out token))
|
||||
throw new WebDropException(ErrorCode.BadRequest);
|
||||
|
||||
return new Credentials(ParseRole(role), token, ParseEndpoint(endpoint, requireEndpoint: true));
|
||||
}
|
||||
|
||||
public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true);
|
||||
|
||||
public static byte[] ParseEndpointHashOptional(string? header) => ParseEndpoint(header, requireEndpoint: false);
|
||||
|
||||
public static Credentials ShareFromHeaders(HttpRequest req, bool requireEndpoint)
|
||||
{
|
||||
var credentials = FromHeaders(req, requireEndpoint);
|
||||
if (credentials.ClaimedRole == Role.Write) throw new WebDropException(ErrorCode.BadRequest);
|
||||
return credentials;
|
||||
}
|
||||
|
||||
private static Role ParseRole(string? value) => value switch
|
||||
{
|
||||
"read" => Role.Read,
|
||||
"write" => Role.Write,
|
||||
"owner" => Role.Owner,
|
||||
_ => throw new WebDropException(ErrorCode.BadRequest),
|
||||
};
|
||||
|
||||
/// <summary>终端密钥 S 为 32 字节,服务端只保存并使用 SHA-256(S)。缺省时为空,仅允许不需要席位的操作。</summary>
|
||||
private static byte[] ParseEndpoint(string? value, bool requireEndpoint)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
if (requireEndpoint) throw new WebDropException(ErrorCode.BadRequest);
|
||||
return [];
|
||||
}
|
||||
if (!Codec.TryDecodeExact(value, 32, out var secret)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
return TokenHash.Compute(secret);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
/// <summary>定期清理过期条目、下载达上限的条目、过期频道,并低频清理孤立 blob。</summary>
|
||||
public sealed class SweepWorker(ChannelService service, ShareService shares, WebDropOptions options, ILogger<SweepWorker> logger) : BackgroundService
|
||||
{
|
||||
private const int OrphanSweepEvery = 20;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var interval = TimeSpan.FromSeconds(Math.Max(1, options.SweepIntervalSeconds));
|
||||
using var timer = new PeriodicTimer(interval);
|
||||
int round = 0;
|
||||
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
bool orphans = round++ % OrphanSweepEvery == 0;
|
||||
int removed = await Task.Run(() => service.Sweep(orphans) + shares.Sweep(), stoppingToken).ConfigureAwait(false);
|
||||
if (removed > 0) logger.LogInformation("清理了 {Count} 个过期条目或频道", removed);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// 只记录异常类型,不记录可能含频道 id 的细节
|
||||
logger.LogError("清理任务失败:{Type}", e.GetType().Name);
|
||||
}
|
||||
}
|
||||
while (await WaitAsync(timer, stoppingToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
private static async Task<bool> WaitAsync(PeriodicTimer timer, CancellationToken ct)
|
||||
{
|
||||
try { return await timer.WaitForNextTickAsync(ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<PublishAot>true</PublishAot>
|
||||
<StripSymbols>true</StripSymbols>
|
||||
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
<AssemblyName>webdrop</AssemblyName>
|
||||
<RootNamespace>WebDrop.Api</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WebDrop.Core\WebDrop.Core.csproj" />
|
||||
<ProjectReference Include="..\WebDrop.Storage\WebDrop.Storage.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public static class WebDropApp
|
||||
{
|
||||
/// <summary>构建应用。<paramref name="configure"/> 在读取配置之前执行,测试用它覆盖数据目录和端口。</summary>
|
||||
public static WebApplication Build(string[] args, Action<WebApplicationBuilder>? configure = null)
|
||||
{
|
||||
var builder = WebApplication.CreateSlimBuilder(args);
|
||||
configure?.Invoke(builder);
|
||||
|
||||
var options = builder.Configuration.GetSection("WebDrop").Get<WebDropOptions>() ?? new WebDropOptions();
|
||||
|
||||
builder.WebHost.ConfigureKestrel(k =>
|
||||
{
|
||||
k.AddServerHeader = false;
|
||||
k.Limits.MaxRequestBodySize = 2 * 1024 * 1024;
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton(options);
|
||||
builder.Services.AddSingleton(options.Limits);
|
||||
builder.Services.AddSingleton<IClock, SystemClock>();
|
||||
builder.Services.AddSingleton<IBlobStore>(_ => new DiskBlobStore(Path.Combine(options.DataDir, "blobs")));
|
||||
builder.Services.AddSingleton<SqliteChannelStore>(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "webdrop.db")));
|
||||
builder.Services.AddSingleton<IResourceStore>(sp => sp.GetRequiredService<SqliteChannelStore>());
|
||||
builder.Services.AddSingleton<ICaptchaVerifier>(sp => CreateCaptchaVerifier(options.Captcha, sp));
|
||||
builder.Services.AddSingleton<ChannelHub>();
|
||||
builder.Services.AddSingleton<IChannelEvents>(sp => sp.GetRequiredService<ChannelHub>());
|
||||
builder.Services.AddSingleton<ChannelService>();
|
||||
builder.Services.AddSingleton<ShareService>(sp => new ShareService(
|
||||
sp.GetRequiredService<IResourceStore>(), sp.GetRequiredService<IBlobStore>(), sp.GetRequiredService<IClock>(),
|
||||
options.Limits, options.ShareDraftTtlSeconds));
|
||||
builder.Services.AddHostedService<SweepWorker>();
|
||||
|
||||
if (options.CorsOrigins.Length > 0)
|
||||
{
|
||||
builder.Services.AddCors(c => c.AddDefaultPolicy(p => p
|
||||
.WithOrigins(options.CorsOrigins)
|
||||
.AllowAnyMethod()
|
||||
.WithHeaders("Authorization", "Content-Type", "Last-Event-ID",
|
||||
"X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Label", "X-WD-Max-Downloads")
|
||||
.WithExposedHeaders("X-WD-Last-Seq")
|
||||
.SetPreflightMaxAge(TimeSpan.FromHours(1))));
|
||||
}
|
||||
|
||||
builder.Services.AddRateLimiter(r =>
|
||||
{
|
||||
r.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
// 限流状态只在内存里,按 IP 分区,不落盘
|
||||
r.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(ClientKey(ctx), _ => PerMinute(options.RequestsPerMinute)));
|
||||
r.AddPolicy(Endpoints.CreatePolicy, ctx =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(ClientKey(ctx), _ => PerMinute(options.ChannelCreatesPerMinute)));
|
||||
r.OnRejected = async (context, ct) =>
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(
|
||||
new ErrorBody { Error = ErrorCode.RateLimited.ToWire() }, ApiJson.Default.ErrorBody, cancellationToken: ct);
|
||||
};
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (options.TrustForwardedHeaders)
|
||||
{
|
||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor,
|
||||
KnownIPNetworks = { },
|
||||
KnownProxies = { },
|
||||
ForwardLimit = 1,
|
||||
});
|
||||
}
|
||||
|
||||
app.Use(SecurityHeaders);
|
||||
app.Use(HandleErrors);
|
||||
if (options.CorsOrigins.Length > 0) app.UseCors();
|
||||
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat });
|
||||
app.UseRateLimiter();
|
||||
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>());
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>配置不完整就在启动时失败,而不是等到第一个用户创建频道时才发现。</summary>
|
||||
private static ICaptchaVerifier CreateCaptchaVerifier(CaptchaOptions o, IServiceProvider sp)
|
||||
{
|
||||
switch (o.Provider.ToLowerInvariant())
|
||||
{
|
||||
case "none":
|
||||
return new NullCaptchaVerifier();
|
||||
case "cap":
|
||||
if (!Uri.TryCreate(o.SiteverifyUrl, UriKind.Absolute, out var verifyUri) || verifyUri.Scheme is not ("http" or "https"))
|
||||
throw new InvalidOperationException("WebDrop:Captcha:SiteverifyUrl 必须是 http(s) 绝对地址");
|
||||
if (!Uri.TryCreate(o.ApiEndpoint, UriKind.Absolute, out var apiUri) || apiUri.Scheme is not ("http" or "https"))
|
||||
throw new InvalidOperationException("WebDrop:Captcha:ApiEndpoint 必须是 http(s) 绝对地址(前端 widget 要连它)");
|
||||
if (string.IsNullOrEmpty(o.Secret))
|
||||
throw new InvalidOperationException("WebDrop:Captcha:Secret 不能为空");
|
||||
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(Math.Max(1, o.TimeoutSeconds)) };
|
||||
return new CapCaptchaVerifier(http, o, sp.GetRequiredService<ILogger<CapCaptchaVerifier>>());
|
||||
default:
|
||||
throw new InvalidOperationException($"WebDrop:Captcha:Provider 只能是 none 或 cap,当前为 {o.Provider}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ClientKey(HttpContext ctx) => ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
private static FixedWindowRateLimiterOptions PerMinute(int permits) => new()
|
||||
{
|
||||
PermitLimit = Math.Max(1, permits),
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
};
|
||||
|
||||
/// <summary>API 不提供任何页面。禁用缓存、禁止嗅探、不发送 Referer、不允许被嵌入。</summary>
|
||||
private static Task SecurityHeaders(HttpContext ctx, RequestDelegate next)
|
||||
{
|
||||
var h = ctx.Response.Headers;
|
||||
h["Referrer-Policy"] = "no-referrer";
|
||||
h["X-Content-Type-Options"] = "nosniff";
|
||||
h["Cache-Control"] = "no-store";
|
||||
h["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'";
|
||||
return next(ctx);
|
||||
}
|
||||
|
||||
/// <summary>统一的错误出口:业务错误映射为 {"error": code};未预期的错误只返回 500,不暴露细节。</summary>
|
||||
private static async Task HandleErrors(HttpContext ctx, RequestDelegate next)
|
||||
{
|
||||
try
|
||||
{
|
||||
await next(ctx);
|
||||
}
|
||||
catch (WebDropException e)
|
||||
{
|
||||
await WriteError(ctx, e.Code, e.OldestSeq);
|
||||
}
|
||||
catch (BadHttpRequestException e)
|
||||
{
|
||||
await WriteError(ctx, e.StatusCode == StatusCodes.Status413PayloadTooLarge ? ErrorCode.TooLarge : ErrorCode.BadRequest, null);
|
||||
}
|
||||
catch (OperationCanceledException) when (ctx.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
// 客户端已断开
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ctx.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("WebDrop")
|
||||
.LogError("未处理的异常:{Type}", e.GetType().Name);
|
||||
if (ctx.Response.HasStarted) { ctx.Abort(); return; }
|
||||
ctx.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await ctx.Response.WriteAsJsonAsync(new ErrorBody { Error = "internal_error" }, ApiJson.Default.ErrorBody);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteError(HttpContext ctx, ErrorCode code, long? oldestSeq)
|
||||
{
|
||||
if (ctx.Response.HasStarted)
|
||||
{
|
||||
ctx.Abort(); // 流已经开始,无法再改状态码,直接断开让客户端续传
|
||||
return;
|
||||
}
|
||||
|
||||
// 不能调用 Response.Clear():它会把前面中间件设置的安全头和 CORS 头一并清掉,
|
||||
// 浏览器端就读不到错误体了。响应尚未开始,直接设状态码并写 JSON 即可。
|
||||
ctx.Response.StatusCode = code.ToStatus();
|
||||
if (code == ErrorCode.Gone && oldestSeq is { } oldest)
|
||||
await ctx.Response.WriteAsJsonAsync(new GoneLogBody { OldestSeq = oldest }, ApiJson.Default.GoneLogBody);
|
||||
else
|
||||
await ctx.Response.WriteAsJsonAsync(new ErrorBody { Error = code.ToWire() }, ApiJson.Default.ErrorBody);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Api;
|
||||
|
||||
/// <summary>配置节 "WebDrop"。环境变量形式:WebDrop__DataDir、WebDrop__Captcha__Provider ……</summary>
|
||||
public sealed class WebDropOptions
|
||||
{
|
||||
public string DataDir { get; set; } = "data";
|
||||
|
||||
/// <summary>允许跨域访问的前端源。前端托管在独立域名,必须在此列出;为空则不发 CORS 头。</summary>
|
||||
public string[] CorsOrigins { get; set; } = [];
|
||||
|
||||
/// <summary>在反向代理之后部署时开启,以便限流按真实客户端 IP 分区。注意代理本身不受信任。</summary>
|
||||
public bool TrustForwardedHeaders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 声明部署层已启用 HTTP/3(例如由反向代理终结 QUIC),仅用于 /info 的通告。
|
||||
/// 服务端进程本身不内置 QUIC:CreateSlimBuilder 不含 HTTP/3,需要时再接入。
|
||||
/// </summary>
|
||||
public bool EnableHttp3 { get; set; }
|
||||
|
||||
public int RequestsPerMinute { get; set; } = 600;
|
||||
public int ChannelCreatesPerMinute { get; set; } = 30;
|
||||
public int ShareDraftTtlSeconds { get; set; } = 86_400;
|
||||
public int SweepIntervalSeconds { get; set; } = 30;
|
||||
|
||||
public ServerLimits Limits { get; set; } = new();
|
||||
|
||||
public CaptchaOptions Captcha { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建频道的验证码。工作量证明完全交给 Cap(https://github.com/tiagozip/cap),本项目不自己实现。
|
||||
/// 选 "cap" 时需要部署一个 Cap Standalone(Docker),并在其中建一个站点密钥。
|
||||
/// </summary>
|
||||
public sealed class CaptchaOptions
|
||||
{
|
||||
/// <summary>"none"(默认,不校验)或 "cap"。</summary>
|
||||
public string Provider { get; set; } = "none";
|
||||
|
||||
/// <summary>浏览器里的 Cap widget 连接的地址,形如 https://cap.example.com/<site_key>/。会通过 /info 下发给前端。</summary>
|
||||
public string ApiEndpoint { get; set; } = "";
|
||||
|
||||
/// <summary>本服务调用 siteverify 的地址,形如 https://cap.example.com/<site_key>/siteverify。可以是内网地址。</summary>
|
||||
public string SiteverifyUrl { get; set; } = "";
|
||||
|
||||
/// <summary>Cap 站点密钥对应的 secret,只在服务端使用,不得下发给前端。</summary>
|
||||
public string Secret { get; set; } = "";
|
||||
|
||||
public int TimeoutSeconds { get; set; } = 5;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.AspNetCore.Hosting.Diagnostics": "None"
|
||||
}
|
||||
},
|
||||
"WebDrop": {
|
||||
"DataDir": "data",
|
||||
"CorsOrigins": [],
|
||||
"TrustForwardedHeaders": false,
|
||||
"EnableHttp3": false,
|
||||
"RequestsPerMinute": 600,
|
||||
"ChannelCreatesPerMinute": 30,
|
||||
"ShareDraftTtlSeconds": 86400,
|
||||
"SweepIntervalSeconds": 30,
|
||||
"Captcha": {
|
||||
"Provider": "none",
|
||||
"ApiEndpoint": "",
|
||||
"SiteverifyUrl": "",
|
||||
"Secret": "",
|
||||
"TimeoutSeconds": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user