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", }; }