using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; namespace MoShiro.Backend.Api; /// 把内嵌前端挂到根路径:只响应 GET 和 HEAD,最低优先级,不覆盖任何后端自己的路径。 public static class StaticSiteEndpoints { /// 后端自己的路径前缀:这些路径没有匹配到端点时要得到后端的 404(JSON),不能被前端的 HTML 回退吞掉。 private static readonly string[] s_reserved = ["/api", "/openapi", "/install", "/healthz"]; // 页面的 CSP:脚本只许同源;样式允许内联(React 的 style 属性);连接允许 https(管理端可以添加别的后端作为数据源)和本机 http private const string PageCsp = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; " + "connect-src 'self' https: http://localhost:* http://127.0.0.1:*; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"; private const string AssetCsp = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; public static IEndpointRouteBuilder MapStaticSite(this IEndpointRouteBuilder app) { StaticSite site = StaticSite.FromAssembly(typeof(StaticSite).Assembly); app.MapMethods("/{**path}", ["GET", "HEAD"], (HttpContext http) => Serve(site, http)).ExcludeFromDescription(); return app; } internal static IResult Serve(StaticSite site, HttpContext http) { string path = http.Request.Path.Value ?? "/"; if (IsReserved(path)) { return Results.StatusCode(StatusCodes.Status404NotFound); } SiteResolution resolution = site.Resolve(path); if (resolution.Redirect is { } location) { // 固定的站内路径,不拼请求里的任何东西(没有开放重定向,也不依赖 Host) return Results.Redirect(location, permanent: true); } if (resolution.Asset is not { } asset) { return Results.StatusCode(StatusCodes.Status404NotFound); } IHeaderDictionary headers = http.Response.Headers; headers.XContentTypeOptions = "nosniff"; headers["Referrer-Policy"] = "no-referrer"; headers.ETag = asset.ETag; headers.CacheControl = asset.CacheControl; headers.ContentSecurityPolicy = asset.Html ? PageCsp : AssetCsp; if (asset.Html) { headers.XFrameOptions = "DENY"; headers["Permissions-Policy"] = "publickey-credentials-get=(self), publickey-credentials-create=(self), camera=(), microphone=(), geolocation=()"; } if (http.Request.Headers.IfNoneMatch.Contains(asset.ETag)) { return Results.StatusCode(StatusCodes.Status304NotModified); } return Results.Bytes(asset.Content, asset.ContentType); } private static bool IsReserved(string path) { foreach (string prefix in s_reserved) { if (path.StartsWith(prefix, StringComparison.Ordinal) && (path.Length == prefix.Length || path[prefix.Length] == '/' || path[prefix.Length] == '.')) { return true; } } return false; } }