using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; namespace WebDrop.Frontend; /// 在同源模式下,从本程序集资源提供 Vite 前端。 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 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 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 { "'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)}"; } }