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

173 lines
6.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Reflection;
using System.Security.Cryptography;
namespace MoShiro.Backend.Api;
/// <summary>一个可提供的前端文件。<c>Html</c> 的页面带 CSP;其余带最严格的 CSP,免得直接打开 SVG 时执行脚本。</summary>
public sealed record SiteAsset(byte[] Content, string ContentType, string ETag, string CacheControl, bool Html);
/// <summary>
/// 解析的结果:<c>Asset</c> 是要返回的文件;<c>Redirect</c> 是要跳转的地址(只会是站内固定的路径,不来自请求);都为空就是 404。
/// </summary>
public readonly record struct SiteResolution(SiteAsset? Asset, string? Redirect);
/// <summary>
/// 内嵌在 Backend 里的前端:展示端在 <c>/</c>,管理端在 <c>/admin/</c>。
/// <para>
/// 安全做法(线上常见的静态文件穿越和 SPA 回退问题):
/// 文件全部是启动时从程序集资源读进内存的<b>白名单</b>,请求时<b>不碰文件系统</b>,所以没有路径穿越和符号链接的问题;
/// 路径里出现 <c>..</c>、<c>.</c>、反斜杠、<c>%</c>(没解码干净的编码)、控制字符、空段(<c>//</c>)、过长,一律 404,不去「规范化」它;
/// SPA 回退只给没有扩展名的路径,缺失的文件(<c>/assets/x.js</c>)返回 404 而不是 HTML;
/// <c>/api</c> 等后端自己的路径由调用方先排除,所以回退不会吞掉 API 的 404。
/// </para>
/// </summary>
public sealed class StaticSite
{
public const string AdminPrefix = "/admin/";
private const int MaxPathLength = 1024;
private readonly Dictionary<string, SiteAsset> _files = new(StringComparer.Ordinal);
private readonly SiteAsset? _displayIndex;
private readonly SiteAsset? _adminIndex;
/// <param name="files">(站内路径,内容);站内路径以 <c>/</c> 开头,管理端的以 <c>/admin/</c> 开头。</param>
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;
/// <summary>从程序集的嵌入资源读取:<c>www/display/…</c> 对应 <c>/…</c>,<c>www/admin/…</c> 对应 <c>/admin/…</c>。没有嵌入前端(本地没构建)时是空站点。</summary>
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);
}
/// <summary>
/// 路径是不是「干净」的:以 <c>/</c> 开头、不过长、没有反斜杠、<c>%</c>、控制字符、<c>.</c> / <c>..</c> 段和中间的空段。
/// 不「规范化」,而是直接拒绝——规范化正是各种穿越绕过(<c>%2e%2e</c>、<c>..%5c</c>、双重编码)的来源。
/// </summary>
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",
};
}