diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs
index f064f0e..d47b1f3 100644
--- a/backend/src/MoShiro.Backend.Api/ApiModule.cs
+++ b/backend/src/MoShiro.Backend.Api/ApiModule.cs
@@ -119,6 +119,7 @@ public static class ApiModule
OpsEndpoints.Map(app);
app.MapOpenApi("/openapi/v1.json");
+ app.MapStaticSite();
return app;
}
diff --git a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj
index 3ef7001..f824f56 100644
--- a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj
+++ b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj
@@ -11,4 +11,11 @@
+
+
+
+
+
+
+
diff --git a/backend/src/MoShiro.Backend.Api/Web/StaticSite.cs b/backend/src/MoShiro.Backend.Api/Web/StaticSite.cs
new file mode 100644
index 0000000..2b892a1
--- /dev/null
+++ b/backend/src/MoShiro.Backend.Api/Web/StaticSite.cs
@@ -0,0 +1,172 @@
+using System.Reflection;
+using System.Security.Cryptography;
+
+namespace MoShiro.Backend.Api;
+
+/// 一个可提供的前端文件。Html 的页面带 CSP;其余带最严格的 CSP,免得直接打开 SVG 时执行脚本。
+public sealed record SiteAsset(byte[] Content, string ContentType, string ETag, string CacheControl, bool Html);
+
+///
+/// 解析的结果:Asset 是要返回的文件;Redirect 是要跳转的地址(只会是站内固定的路径,不来自请求);都为空就是 404。
+///
+public readonly record struct SiteResolution(SiteAsset? Asset, string? Redirect);
+
+///
+/// 内嵌在 Backend 里的前端:展示端在 /,管理端在 /admin/。
+///
+/// 安全做法(线上常见的静态文件穿越和 SPA 回退问题):
+/// 文件全部是启动时从程序集资源读进内存的白名单,请求时不碰文件系统,所以没有路径穿越和符号链接的问题;
+/// 路径里出现 ..、.、反斜杠、%(没解码干净的编码)、控制字符、空段(//)、过长,一律 404,不去「规范化」它;
+/// SPA 回退只给没有扩展名的路径,缺失的文件(/assets/x.js)返回 404 而不是 HTML;
+/// /api 等后端自己的路径由调用方先排除,所以回退不会吞掉 API 的 404。
+///
+///
+public sealed class StaticSite
+{
+ public const string AdminPrefix = "/admin/";
+ private const int MaxPathLength = 1024;
+
+ private readonly Dictionary _files = new(StringComparer.Ordinal);
+ private readonly SiteAsset? _displayIndex;
+ private readonly SiteAsset? _adminIndex;
+
+ /// (站内路径,内容);站内路径以 / 开头,管理端的以 /admin/ 开头。
+ public StaticSite(IEnumerable<(string Path, byte[] Content)> files)
+ {
+ foreach ((string path, byte[] content) in files)
+ {
+ if (!IsCleanPath(path))
+ {
+ continue;
+ }
+
+ _files[path] = Describe(path, content);
+ }
+
+ _displayIndex = _files.GetValueOrDefault("/index.html");
+ _adminIndex = _files.GetValueOrDefault(AdminPrefix + "index.html");
+ if (_displayIndex is not null)
+ {
+ _files["/"] = _displayIndex;
+ }
+
+ if (_adminIndex is not null)
+ {
+ _files[AdminPrefix] = _adminIndex;
+ }
+ }
+
+ public int Count => _files.Count;
+
+ /// 从程序集的嵌入资源读取:www/display/… 对应 /…,www/admin/… 对应 /admin/…。没有嵌入前端(本地没构建)时是空站点。
+ public static StaticSite FromAssembly(Assembly assembly)
+ {
+ var files = new List<(string, byte[])>();
+ foreach (string name in assembly.GetManifestResourceNames())
+ {
+ string? url = name.StartsWith("www/display/", StringComparison.Ordinal) ? "/" + name["www/display/".Length..]
+ : name.StartsWith("www/admin/", StringComparison.Ordinal) ? AdminPrefix + name["www/admin/".Length..]
+ : null;
+ if (url is null)
+ {
+ continue;
+ }
+
+ using Stream stream = assembly.GetManifestResourceStream(name)!;
+ using var memory = new MemoryStream();
+ stream.CopyTo(memory);
+ files.Add((url.Replace('\\', '/'), memory.ToArray()));
+ }
+
+ return new StaticSite(files);
+ }
+
+ public SiteResolution Resolve(string? path)
+ {
+ if (!IsCleanPath(path))
+ {
+ return default;
+ }
+
+ if (_files.TryGetValue(path, out SiteAsset? exact))
+ {
+ return new SiteResolution(exact, null);
+ }
+
+ if (path == "/admin")
+ {
+ return _adminIndex is null ? default : new SiteResolution(null, AdminPrefix);
+ }
+
+ // 缺失的文件不回退成 HTML(否则脚本和样式 404 变成「HTML 当脚本」,很难排查,也可能被缓存污染)
+ string last = path[(path.LastIndexOf('/') + 1)..];
+ if (last.Contains('.', StringComparison.Ordinal))
+ {
+ return default;
+ }
+
+ SiteAsset? index = path.StartsWith(AdminPrefix, StringComparison.Ordinal) ? _adminIndex : _displayIndex;
+ return index is null ? default : new SiteResolution(index, null);
+ }
+
+ ///
+ /// 路径是不是「干净」的:以 / 开头、不过长、没有反斜杠、%、控制字符、. / .. 段和中间的空段。
+ /// 不「规范化」,而是直接拒绝——规范化正是各种穿越绕过(%2e%2e、..%5c、双重编码)的来源。
+ ///
+ public static bool IsCleanPath([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] string? path)
+ {
+ if (string.IsNullOrEmpty(path) || path[0] != '/' || path.Length > MaxPathLength)
+ {
+ return false;
+ }
+
+ foreach (char c in path)
+ {
+ if (c is '\\' or '%' or '\0' || c < 0x20 || c == 0x7f || c > 0x7e)
+ {
+ return false;
+ }
+ }
+
+ string[] segments = path.Split('/');
+ for (int i = 1; i < segments.Length; i++)
+ {
+ string segment = segments[i];
+ bool trailing = i == segments.Length - 1;
+ if (segment is "." or ".." || (segment.Length == 0 && !trailing))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static SiteAsset Describe(string path, byte[] content)
+ {
+ string extension = Path.GetExtension(path).ToLowerInvariant();
+ bool html = extension == ".html";
+ string etag = "\"" + Convert.ToHexStringLower(SHA256.HashData(content))[..32] + "\"";
+ string cache = html ? "no-cache" : path.Contains("/assets/", StringComparison.Ordinal) ? "public, max-age=31536000, immutable" : "public, max-age=3600";
+ return new SiteAsset(content, ContentTypeOf(extension), etag, cache, html);
+ }
+
+ private static string ContentTypeOf(string extension) => extension switch
+ {
+ ".html" => "text/html; charset=utf-8",
+ ".js" or ".mjs" => "text/javascript; charset=utf-8",
+ ".css" => "text/css; charset=utf-8",
+ ".svg" => "image/svg+xml",
+ ".png" => "image/png",
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".webp" => "image/webp",
+ ".gif" => "image/gif",
+ ".ico" => "image/x-icon",
+ ".woff2" => "font/woff2",
+ ".woff" => "font/woff",
+ ".json" => "application/json",
+ ".webmanifest" => "application/manifest+json",
+ ".txt" => "text/plain; charset=utf-8",
+ _ => "application/octet-stream",
+ };
+}
diff --git a/backend/src/MoShiro.Backend.Api/Web/StaticSiteEndpoints.cs b/backend/src/MoShiro.Backend.Api/Web/StaticSiteEndpoints.cs
new file mode 100644
index 0000000..cf786d1
--- /dev/null
+++ b/backend/src/MoShiro.Backend.Api/Web/StaticSiteEndpoints.cs
@@ -0,0 +1,78 @@
+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;
+ }
+}
diff --git a/backend/tests/MoShiro.Backend.Tests/StaticSiteTests.cs b/backend/tests/MoShiro.Backend.Tests/StaticSiteTests.cs
new file mode 100644
index 0000000..a16e76d
--- /dev/null
+++ b/backend/tests/MoShiro.Backend.Tests/StaticSiteTests.cs
@@ -0,0 +1,227 @@
+using System.Net;
+using System.Text;
+using MoShiro.Backend.Api;
+
+namespace MoShiro.Backend.Tests;
+
+///
+/// 内嵌前端:白名单、不碰文件系统,路径穿越的各种写法全部 404;SPA 回退只给没有扩展名的路径,不吞 API 的 404;
+/// 页面带 CSP 等响应头,哈希文件名的资源长缓存、入口页不缓存。
+///
+public sealed class StaticSiteTests
+{
+ private static StaticSite Site() => new(
+ [
+ ("/index.html", Encoding.UTF8.GetBytes("display")),
+ ("/assets/app-1a2b.js", Encoding.UTF8.GetBytes("console.log(1)")),
+ ("/flags/cn.svg", Encoding.UTF8.GetBytes("")),
+ ("/admin/index.html", Encoding.UTF8.GetBytes("admin")),
+ ("/admin/assets/app-3c4d.css", Encoding.UTF8.GetBytes("body{}")),
+ ("/../secret.txt", Encoding.UTF8.GetBytes("must never be registered")),
+ ]);
+
+ private static string Text(SiteAsset? asset) => Encoding.UTF8.GetString(asset!.Content);
+
+ [Fact]
+ public void KnownFiles_AreServed_WithTypeAndCaching()
+ {
+ StaticSite site = Site();
+
+ Assert.Equal("display", Text(site.Resolve("/").Asset));
+ Assert.Equal("admin", Text(site.Resolve("/admin/").Asset));
+
+ SiteAsset? js = site.Resolve("/assets/app-1a2b.js").Asset;
+ Assert.StartsWith("text/javascript", js!.ContentType, StringComparison.Ordinal);
+ Assert.Contains("immutable", js.CacheControl, StringComparison.Ordinal);
+ Assert.False(js.Html);
+
+ SiteAsset? index = site.Resolve("/index.html").Asset;
+ Assert.Equal("no-cache", index!.CacheControl);
+ Assert.True(index.Html);
+ Assert.Equal("image/svg+xml", site.Resolve("/flags/cn.svg").Asset!.ContentType);
+ Assert.StartsWith("text/css", site.Resolve("/admin/assets/app-3c4d.css").Asset!.ContentType, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void AdminWithoutSlash_RedirectsToAFixedPath()
+ {
+ SiteResolution resolution = Site().Resolve("/admin");
+
+ Assert.Null(resolution.Asset);
+ Assert.Equal("/admin/", resolution.Redirect);
+ }
+
+ [Theory]
+ [InlineData("/../secret.txt")]
+ [InlineData("/admin/../../etc/passwd")]
+ [InlineData("/assets/../index.html")]
+ [InlineData("/./index.html")]
+ [InlineData("/%2e%2e/secret.txt")]
+ [InlineData("/%2E%2E%2Fsecret.txt")]
+ [InlineData("/..%2fsecret.txt")]
+ [InlineData("/..%5csecret.txt")]
+ [InlineData("/%252e%252e/secret.txt")]
+ [InlineData("/..\\secret.txt")]
+ [InlineData("/assets\\app-1a2b.js")]
+ [InlineData("/assets//app-1a2b.js")]
+ [InlineData("//assets/app-1a2b.js")]
+ [InlineData("/index.html\0.png")]
+ [InlineData("/index.html\r\nSet-Cookie: a=b")]
+ [InlineData("/文件")]
+ [InlineData("assets/app-1a2b.js")]
+ [InlineData("")]
+ public void TraversalAndMalformedPaths_AreNotFound_NeverNormalized(string path)
+ {
+ SiteResolution resolution = Site().Resolve(path);
+
+ Assert.Null(resolution.Asset);
+ Assert.Null(resolution.Redirect);
+ }
+
+ [Fact]
+ public void OverlongPath_IsNotFound()
+ {
+ Assert.Null(Site().Resolve("/" + new string('a', 5000)).Asset);
+ }
+
+ [Fact]
+ public void SpaFallback_OnlyForPathsWithoutAnExtension()
+ {
+ StaticSite site = Site();
+
+ Assert.Equal("display", Text(site.Resolve("/lines/abc").Asset));
+ Assert.Equal("admin", Text(site.Resolve("/admin/agents").Asset));
+ Assert.Equal("admin", Text(site.Resolve("/admin/system/trust").Asset));
+
+ // 缺失的文件不是页面:脚本、样式、图片 404 要保持 404,不能变成 HTML
+ Assert.Null(site.Resolve("/assets/missing.js").Asset);
+ Assert.Null(site.Resolve("/admin/assets/missing.css").Asset);
+ Assert.Null(site.Resolve("/favicon.ico").Asset);
+ Assert.Null(site.Resolve("/robots.txt").Asset);
+ }
+
+ [Fact]
+ public void AdminAndDisplay_AreNotMixed()
+ {
+ StaticSite site = Site();
+
+ // 展示端的路径永远不会得到管理端的页面,反过来管理端的前缀之外也一样
+ Assert.Equal("display", Text(site.Resolve("/administrator").Asset));
+ Assert.Equal("display", Text(site.Resolve("/adminx/agents").Asset));
+ Assert.Null(site.Resolve("/admin/index.htm").Asset);
+ }
+
+ [Fact]
+ public void EmptySite_ServesNothing()
+ {
+ var site = new StaticSite([]);
+
+ Assert.Equal(0, site.Count);
+ Assert.Null(site.Resolve("/").Asset);
+ Assert.Null(site.Resolve("/admin/agents").Asset);
+ Assert.Null(site.Resolve("/admin").Redirect);
+ }
+
+ [Fact]
+ public void UncleanPathsAreRejectedWhenRegistering()
+ {
+ StaticSite site = Site();
+
+ Assert.Equal(5, site.Count - 2); // 五个干净的文件;两个别名 "/" 和 "/admin/" 不算;"/../secret.txt" 没登记
+ }
+}
+
+/// HTTP 层:后端自己的路径不会被前端的回退吞掉;响应头对。
+[Collection("probe-e2e")]
+public sealed class StaticSiteHttpTests
+{
+ private readonly BackendFixture _backend;
+
+ public StaticSiteHttpTests(BackendFixture backend)
+ {
+ _backend = backend;
+ }
+
+ [Theory]
+ [InlineData("/api/admin/does-not-exist")]
+ [InlineData("/api/public/does-not-exist")]
+ [InlineData("/api/agent/v1/nope")]
+ [InlineData("/api")]
+ [InlineData("/openapi/missing.json")]
+ [InlineData("/install/missing")]
+ [InlineData("/healthz/x")]
+ public async Task BackendPaths_KeepTheirOwnNotFound_NotTheFrontend(string path)
+ {
+ using HttpClient client = _backend.NewClient();
+ using HttpResponseMessage response = await client.GetAsync(path);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ string body = await response.Content.ReadAsStringAsync();
+ Assert.DoesNotContain("&2; exit 1; }
+
(cd "$root/backend" && dotnet publish src/MoShiro.Backend \
-m:3 -c Release -r "$rid" --self-contained true \
-p:DebugType=none -p:DebugSymbols=false \
diff --git a/docs/deployment.md b/docs/deployment.md
index f534dd0..150fd42 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -40,6 +40,8 @@ macOS 的 Apple Silicon 产物带临时 ad-hoc 签名,Intel 产物未签名。
Backend 使用 OCI 标准镜像,通过 `registry.oeo.one/moshiro/moshiro-backend:` 分发,支持 `linux/amd64` 和 `linux/arm64`。容器使用 glibc NativeAOT 单文件程序(动态链接,运行时要加载 libssl;musl 的静态产物做不到),基底为 `mcr.microsoft.com/dotnet/runtime-deps:10.0`(Debian),以非 root 用户运行,在 `8080/tcp` 提供 HTTP 服务;数据库和原始数据统一写入 `/data`。程序启用 InvariantGlobalization,无需 .NET 运行时或 ICU;时间查询使用 UTC。SQLite 从固定版本的官方源码编译并静态链接,不再随镜像携带 `libe_sqlite3.so`。将 `/data` 挂载为持久卷,并在反向代理处配置 HTTPS。
+**前端内嵌在 Backend 里**:CI 构建 Backend 前先构建 `web/display` 和 `web/admin`,`Backend.Api` 把两个 `dist` 嵌进程序,展示端在 `/`,管理端在 `/admin/`,所以面板的域名(如 `https://mo.oeo.one/admin/`)打开就是管理端,passkey 才能绑定在这个域名上(浏览器只允许页面的来源与 RP 域名一致,本机 `localhost` 上的页面注册不了绑定 `mo.oeo.one` 的 passkey)。本地没有构建前端时不嵌入,Backend 只提供 API。文件是启动时读进内存的白名单,请求时不访问文件系统;带 `..`、反斜杠、`%`、控制字符或空段的路径一律 404,缺失的文件(带扩展名)404,只有无扩展名的路径回退到入口页;`/api`、`/openapi`、`/install`、`/healthz` 下没有匹配到的路径仍是后端的 JSON 404。页面带 CSP(脚本只许同源)、`nosniff`、`X-Frame-Options: DENY`,入口页 `no-cache`,哈希文件名的资源 `immutable`,不嵌入 source map。部署时设置 `MoShiro__PublicUrl=https://<面板域名>`(`deploy-node.sh backend --public-url …`),WebAuthn 的域名和允许的来源由它派生。
+
在部署目录保存此仓库的 `compose.yaml`,先通过部署平台的环境变量或密钥管理设置 `MOSHIRO_ADMIN_PASSWORD`(可选设置 `MOSHIRO_ADMIN_USER`、`MOSHIRO_VERSION`),然后启动:
```sh