using System.Threading.RateLimiting; using WebDrop.Core; using WebDrop.Frontend; using WebDrop.Storage; namespace WebDrop.Api; public static class WebDropApp { /// 构建应用。 在读取配置之前执行,测试用它覆盖数据目录和端口。 public static WebApplication Build(string[] args, Action? configure = null) { var builder = WebApplication.CreateSlimBuilder(args); configure?.Invoke(builder); var options = builder.Configuration.GetSection("WebDrop").Get() ?? 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(); builder.Services.AddSingleton(_ => new DiskBlobStore(Path.Combine(options.DataDir, "blobs"))); builder.Services.AddSingleton(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "webdrop.db"))); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(sp => CreateCaptchaVerifier(options.Captcha, sp)); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new ShareService( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), options.Limits, options.ShareDraftTtlSeconds)); builder.Services.AddHostedService(); 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(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(); EmbeddedFrontend.Use(app, options.Captcha.ApiEndpoint); app.MapApi(options, app.Services.GetRequiredService()); return app; } /// 配置不完整就在启动时失败,而不是等到第一个用户创建频道时才发现。 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>()); 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, }; /// API 默认禁止缓存、嗅探和嵌入;嵌入式前端的页面策略由静态模块单独设置。 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); } /// 统一的错误出口:业务错误映射为 {"error": code};未预期的错误只返回 500,不暴露细节。 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().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); } }