199 lines
10 KiB
C#
199 lines
10 KiB
C#
using System.Threading.RateLimiting;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.Extensions.Options;
|
|
using WebDrop.Core;
|
|
using WebDrop.Frontend;
|
|
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, "v2", "blobs")));
|
|
builder.Services.AddSingleton<SqliteChannelStore>(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "v2", "webdrop.db")));
|
|
builder.Services.AddSingleton<SqliteAccountStore>(_ => new SqliteAccountStore(Path.Combine(options.DataDir, "v2", "webdrop.db")));
|
|
builder.Services.AddSingleton<PasskeyCeremonies>();
|
|
builder.Services.AddScoped<PasskeyUserStore>();
|
|
builder.Services.AddScoped<UserManager<PasskeyAccount>>(sp => new(
|
|
sp.GetRequiredService<PasskeyUserStore>(), Options.Create(new IdentityOptions()), new PasswordHasher<PasskeyAccount>(), [], [],
|
|
new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), sp, sp.GetRequiredService<ILogger<UserManager<PasskeyAccount>>>()));
|
|
builder.Services.Configure<IdentityPasskeyOptions>(o =>
|
|
{
|
|
o.ServerDomain = options.PasskeyRpId;
|
|
o.UserVerificationRequirement = "required"; o.ResidentKeyRequirement = "required";
|
|
o.AttestationConveyancePreference = "none"; o.AuthenticatorTimeout = TimeSpan.FromMinutes(2);
|
|
o.IsAllowedAlgorithm = algorithm => algorithm == -7;
|
|
o.ValidateOrigin = context => ValueTask.FromResult(!context.CrossOrigin && context.TopOrigin is null
|
|
&& Uri.TryCreate(context.Origin, UriKind.Absolute, out var origin)
|
|
&& (options.CorsOrigins.Contains(origin.GetLeftPart(UriPartial.Authority), StringComparer.Ordinal)
|
|
|| origin.GetLeftPart(UriPartial.Authority) == $"{context.HttpContext.Request.Scheme}://{context.HttpContext.Request.Host}"));
|
|
});
|
|
builder.Services.AddScoped<IPasskeyHandler<PasskeyAccount>, PasskeyHandler<PasskeyAccount>>();
|
|
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-Max-Downloads", "X-WD-Account",
|
|
"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")
|
|
.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();
|
|
_ = app.Services.GetRequiredService<SqliteChannelStore>();
|
|
|
|
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();
|
|
EmbeddedFrontend.Use(app, options.Captcha.ApiEndpoint);
|
|
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>(), app.Services.GetRequiredService<SqliteAccountStore>());
|
|
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 默认禁止缓存、嗅探和嵌入;嵌入式前端的页面策略由静态模块单独设置。</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);
|
|
}
|
|
}
|