Files
MoShiro/backend/src/MoShiro.Backend.Api/Web/StaticSiteEndpoints.cs
T

79 lines
3.2 KiB
C#

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace MoShiro.Backend.Api;
/// <summary>把内嵌前端挂到根路径:只响应 GET 和 HEAD,最低优先级,不覆盖任何后端自己的路径。</summary>
public static class StaticSiteEndpoints
{
/// <summary>后端自己的路径前缀:这些路径没有匹配到端点时要得到后端的 404(JSON),不能被前端的 HTML 回退吞掉。</summary>
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;
}
}