Files
WebDrop/server/src/WebDrop.Api/WebDropApp.cs
T

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);
}
}