79 lines
3.2 KiB
C#
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;
|
|
}
|
|
}
|