141 lines
5.6 KiB
C#
141 lines
5.6 KiB
C#
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
|
|
namespace WebDrop.Frontend;
|
|
|
|
/// <summary>在同源模式下,从本程序集资源提供 Vite 前端。</summary>
|
|
public static class EmbeddedFrontend
|
|
{
|
|
private const string ResourcePrefix = "WebDrop.Frontend.Assets/";
|
|
|
|
public static void Use(IApplicationBuilder app, string? extraConnectOrigin = null)
|
|
{
|
|
var assembly = typeof(EmbeddedFrontend).Assembly;
|
|
var resources = assembly.GetManifestResourceNames()
|
|
.Where(name => name.StartsWith(ResourcePrefix, StringComparison.Ordinal))
|
|
.ToDictionary(
|
|
name => name[ResourcePrefix.Length..].Replace('\\', '/'),
|
|
StringComparer.Ordinal);
|
|
|
|
if (!resources.ContainsKey("index.html")) return;
|
|
|
|
var contentSecurityPolicy = BuildContentSecurityPolicy(extraConnectOrigin);
|
|
app.Use((HttpContext context, RequestDelegate next) =>
|
|
HandleAsync(context, next, assembly, resources, contentSecurityPolicy));
|
|
}
|
|
|
|
private static async Task HandleAsync(
|
|
HttpContext context,
|
|
RequestDelegate next,
|
|
System.Reflection.Assembly assembly,
|
|
IReadOnlyDictionary<string, string> resources,
|
|
string contentSecurityPolicy)
|
|
{
|
|
if (!(HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)))
|
|
{
|
|
await next(context);
|
|
return;
|
|
}
|
|
|
|
var normalized = NormalizePath(context.Request.Path.Value);
|
|
if (normalized is null)
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
|
return;
|
|
}
|
|
if (IsApiPath(normalized))
|
|
{
|
|
await next(context);
|
|
return;
|
|
}
|
|
|
|
if (resources.TryGetValue(normalized, out var resourceName))
|
|
{
|
|
await SendAsync(context, assembly, resources, normalized, contentSecurityPolicy, resourceName);
|
|
return;
|
|
}
|
|
|
|
// A missing Vite asset must stay a 404. Other unknown paths are client-side routes.
|
|
if (normalized.StartsWith("assets/", StringComparison.Ordinal) || normalized.Equals("favicon.ico", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
|
return;
|
|
}
|
|
|
|
await SendAsync(context, assembly, resources, "index.html", contentSecurityPolicy);
|
|
}
|
|
|
|
private static async Task SendAsync(
|
|
HttpContext context,
|
|
System.Reflection.Assembly assembly,
|
|
IReadOnlyDictionary<string, string> resources,
|
|
string path,
|
|
string contentSecurityPolicy,
|
|
string? resourceName = null)
|
|
{
|
|
resourceName ??= resources[path];
|
|
await using var stream = assembly.GetManifestResourceStream(resourceName);
|
|
if (stream is null)
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
|
return;
|
|
}
|
|
|
|
context.Response.StatusCode = StatusCodes.Status200OK;
|
|
context.Response.ContentType = ContentType(path);
|
|
context.Response.Headers["Cache-Control"] = path.StartsWith("assets/", StringComparison.Ordinal)
|
|
? "public, max-age=31536000, immutable"
|
|
: "no-store";
|
|
|
|
// Dedicated workers enforce the CSP returned with their own script response.
|
|
// Give bundled JS the same app policy so the crypto worker can call the same-origin API.
|
|
if (path.Equals("index.html", StringComparison.Ordinal) || path.EndsWith(".js", StringComparison.OrdinalIgnoreCase))
|
|
context.Response.Headers["Content-Security-Policy"] = contentSecurityPolicy;
|
|
|
|
if (!HttpMethods.IsHead(context.Request.Method))
|
|
await stream.CopyToAsync(context.Response.Body, context.RequestAborted);
|
|
}
|
|
|
|
private static string? NormalizePath(string? path)
|
|
{
|
|
if (string.IsNullOrEmpty(path)) return "index.html";
|
|
var normalized = path.Replace('\\', '/').Trim('/');
|
|
if (normalized.Length == 0) return "index.html";
|
|
if (normalized.Contains('\0') || normalized.Split('/').Any(segment => segment is ".." or "." or ""))
|
|
return null;
|
|
return normalized;
|
|
}
|
|
|
|
private static bool IsApiPath(string path) =>
|
|
path.Equals("api", StringComparison.OrdinalIgnoreCase) ||
|
|
path.StartsWith("api/", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static string ContentType(string path) => Path.GetExtension(path).ToLowerInvariant() switch
|
|
{
|
|
".html" => "text/html; charset=utf-8",
|
|
".js" => "text/javascript; charset=utf-8",
|
|
".css" => "text/css; charset=utf-8",
|
|
".json" => "application/json; charset=utf-8",
|
|
".svg" => "image/svg+xml",
|
|
".png" => "image/png",
|
|
".jpg" or ".jpeg" => "image/jpeg",
|
|
".webp" => "image/webp",
|
|
".ico" => "image/x-icon",
|
|
".woff2" => "font/woff2",
|
|
".wasm" => "application/wasm",
|
|
_ => "application/octet-stream",
|
|
};
|
|
|
|
private static string BuildContentSecurityPolicy(string? extraConnectOrigin)
|
|
{
|
|
var connectSources = new List<string> { "'self'", "ws:", "wss:" };
|
|
if (Uri.TryCreate(extraConnectOrigin, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https")
|
|
connectSources.Add(uri.GetLeftPart(UriPartial.Authority));
|
|
|
|
return "default-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; " +
|
|
"form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; " +
|
|
"img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; worker-src 'self' blob:; " +
|
|
$"connect-src {string.Join(' ', connectSources)}";
|
|
}
|
|
}
|