feat: embed frontend in backend
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/tag/release-backend Pipeline was successful

This commit is contained in:
justme committed 2026-10-02 13:11:10 +08:00
1 parent 22f9bcfa46
commit fbe6abd519
12 files changed
+217 -13

No files matched your search

+11
View File
@@ -1,3 +1,12 @@
FROM node:24.21.0-alpine3.23 AS frontend
WORKDIR /src
COPY web/package.json web/package-lock.json ./web/
COPY packages/core/package.json ./packages/core/package.json
RUN cd web && npm ci --no-audit --no-fund
COPY packages/core/ ./packages/core/
COPY web/ ./web/
RUN npm run build --prefix web
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
RUN apt-get update \
@@ -6,10 +15,12 @@ RUN apt-get update \
COPY server/Directory.Build.props ./server/
COPY server/src/WebDrop.Api/WebDrop.Api.csproj ./server/src/WebDrop.Api/
COPY server/src/WebDrop.Core/WebDrop.Core.csproj ./server/src/WebDrop.Core/
COPY server/src/WebDrop.Frontend/WebDrop.Frontend.csproj ./server/src/WebDrop.Frontend/
COPY server/src/WebDrop.Storage/WebDrop.Storage.csproj ./server/src/WebDrop.Storage/
RUN dotnet restore server/src/WebDrop.Api/WebDrop.Api.csproj \
--runtime linux-x64
COPY server/ ./server/
COPY --from=frontend /src/web/dist/ ./web/dist/
RUN dotnet publish server/src/WebDrop.Api/WebDrop.Api.csproj \
--configuration Release \
--runtime linux-x64 \
+3
View File
@@ -8,6 +8,7 @@
|---|---|
| `src/WebDrop.Core` | 领域类型、规则和业务逻辑(`ChannelService`、`ShareService`),不做 IO,只依赖抽象 |
| `src/WebDrop.Storage` | SQLite 存储(`SqliteChannelStore`)和磁盘 blob(`DiskBlobStore`) |
| `src/WebDrop.Frontend` | 可选嵌入 Vite dist,提供静态文件、SPA fallback 与页面 CSP;不依赖业务层 |
| `src/WebDrop.Api` | Minimal API、WebSocket / SSE / 长轮询、订阅枢纽、后台清理、限流、安全头 |
| `tests/` | `Core.Tests`(单元与服务层,真实 SQLite)、`Api.Tests`(真实 Kestrel 上的集成测试) |
@@ -16,6 +17,7 @@
```powershell
dotnet test # 全部测试
dotnet run --project src/WebDrop.Api # 开发运行,默认 http://localhost:5000
npm --prefix ../web run build # 先构建前端时,dotnet publish 会自动嵌入 web/dist
# NativeAOT 发布(Windows 需要 vswhere.exe 在 PATH 里)
$env:PATH += ";${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer"
@@ -47,6 +49,7 @@ python3 scripts/smoke.py out/linux-x64/webdrop # 跨平台版本,只用
- 单实例部署:订阅枢纽在进程内,多实例需要换成外部发布订阅。
- 日志不记录请求头、请求体、完整 URL;Hosting 诊断日志已关闭,避免频道 id 进入日志。
- NativeAOT:不用 SignalR、EF Core、MVC、反射式 JSON;所有 AOT / 裁剪警告视为错误。
- `WebDrop.Frontend` 只负责嵌入静态资源与 SPA fallback。Dockerfile 自动构建 `web/`;单独发布 API 时,只有 `web/dist` 存在或指定 `WebDropFrontendDist` 后才包含前端。
## 验证码(Cap)
+1
View File
@@ -2,6 +2,7 @@
<Folder Name="/src/">
<Project Path="src/WebDrop.Api/WebDrop.Api.csproj" />
<Project Path="src/WebDrop.Core/WebDrop.Core.csproj" />
<Project Path="src/WebDrop.Frontend/WebDrop.Frontend.csproj" />
<Project Path="src/WebDrop.Storage/WebDrop.Storage.csproj" />
</Folder>
<Folder Name="/tests/">
@@ -11,6 +11,7 @@
<ItemGroup>
<ProjectReference Include="..\WebDrop.Core\WebDrop.Core.csproj" />
<ProjectReference Include="..\WebDrop.Frontend\WebDrop.Frontend.csproj" />
<ProjectReference Include="..\WebDrop.Storage\WebDrop.Storage.csproj" />
</ItemGroup>
+3 -1
View File
@@ -1,5 +1,6 @@
using System.Threading.RateLimiting;
using WebDrop.Core;
using WebDrop.Frontend;
using WebDrop.Storage;
namespace WebDrop.Api;
@@ -80,6 +81,7 @@ public static class WebDropApp
if (options.CorsOrigins.Length > 0) app.UseCors();
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat });
app.UseRateLimiter();
EmbeddedFrontend.Use(app, options.Captcha.ApiEndpoint);
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>());
return app;
}
@@ -114,7 +116,7 @@ public static class WebDropApp
QueueLimit = 0,
};
/// <summary>API 不提供任何页面。禁用缓存、禁止嗅探、不发送 Referer、不允许被嵌入。</summary>
/// <summary>API 默认禁止缓存、嗅探和嵌入;嵌入式前端的页面策略由静态模块单独设置。</summary>
private static Task SecurityHeaders(HttpContext ctx, RequestDelegate next)
{
var h = ctx.Response.Headers;
@@ -0,0 +1,138 @@
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";
if (path.Equals("index.html", StringComparison.Ordinal))
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)}";
}
}
+22
View File
@@ -0,0 +1,22 @@
# WebDrop.Frontend
独立的 ASP.NET Core 模块,负责提供嵌入程序集的 Vite 前端资源。
## 构建
如果 `web/dist/index.html` 存在,项目会将 `web/dist` 作为 embedded resources 编入 `WebDrop.Frontend.dll`。也可通过 MSBuild 属性指定资源目录:
```powershell
dotnet publish ..\WebDrop.Api\WebDrop.Api.csproj -p:WebDropFrontendDist=C:\build\web-dist
```
Dockerfile 会构建 `web/`,再把 dist 传给 .NET 发布阶段。`EmbeddedFrontend.Map` 只映射页面与静态文件,不依赖 `WebDrop.Core` 或存储层。
开发时可以不构建 dist,此时模块不映射页面路由;使用 `npm run dev` 配合 Vite API 代理。
## 路由与响应
- `/` 和未知的前端路由返回 `index.html`,支持 React SPA 深层链接。
- `/assets/*` 从程序集资源读取;缺失资源和 `/api/*` 返回 404。
- 页面使用同源 CSP,并允许配置的 Cap API origin、WebSocket 与 Worker。
- `index.html` 不缓存;Vite `assets/` 使用 immutable 缓存。
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>WebDrop.Frontend</RootNamespace>
<AssemblyName>WebDrop.Frontend</AssemblyName>
<WebDropFrontendDist Condition="'$(WebDropFrontendDist)' == ''">$(MSBuildProjectDirectory)/../../../web/dist</WebDropFrontendDist>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup Condition="Exists('$(WebDropFrontendDist)/index.html')">
<EmbeddedResource Include="$(WebDropFrontendDist)/**/*">
<LogicalName>WebDrop.Frontend.Assets/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
</EmbeddedResource>
</ItemGroup>
</Project>