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