11 Commits
Author SHA1 Message Date
justme c68acfd78b fix: allow embedded worker API requests
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/tag/release-backend Pipeline was successful
2026-10-02 14:12:14 +08:00
justme 10ea48bf04 fix: clarify quick share creation failures
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/tag/release-backend Pipeline was successful
2026-10-02 13:44:03 +08:00
justme fbe6abd519 feat: embed frontend in backend
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/tag/release-backend Pipeline was successful
2026-10-02 13:11:10 +08:00
justme 22f9bcfa46 ci: reuse Buildah layers for backend releases
ci/woodpecker/push/ci Pipeline was successful
2026-10-02 05:02:03 +00:00
justme 46974b120e ci: cache and prefetch WebDrop dependencies
ci/woodpecker/tag/release-backend Pipeline was successful
2026-10-02 04:18:48 +00:00
justme 3332e7bbcf ci: cache and prefetch WebDrop dependencies 2026-10-02 04:18:41 +00:00
justme a182043970 ci: cache native image build dependencies
ci/woodpecker/manual/ci Pipeline failed
ci/woodpecker/manual/release-backend Pipeline was canceled
2026-10-02 11:51:56 +08:00
justme 2150849529 ci: use host network for Buildah image build
ci/woodpecker/manual/ci Pipeline was successful
ci/woodpecker/manual/release-backend Pipeline was successful
2026-10-02 11:39:48 +08:00
justme dde7beee15 ci: publish releases through internal Zot endpoint
ci/woodpecker/manual/ci Pipeline was successful
ci/woodpecker/manual/release-backend Pipeline failed
2026-10-02 11:37:24 +08:00
justme dd6ab78353 ci: checkout tagged source for manual releases
ci/woodpecker/manual/release-backend Pipeline failed
ci/woodpecker/manual/ci Pipeline was successful
2026-10-02 11:34:30 +08:00
justme 9964b4e3ad ci: allow manual semver image publishing 2026-10-02 11:33:34 +08:00
17 changed files with 284 additions and 35 deletions

No files matched your search

+15 -3
View File
@@ -14,6 +14,17 @@ clone:
lfs: false
steps:
prefetch-dependencies:
image: bash
commands:
- mkdir -p "$HOME/.cache/woodpecker/webdrop/npm" "$HOME/.cache/woodpecker/webdrop/nuget"
- cd packages/core
- npm ci --cache "$HOME/.cache/woodpecker/webdrop/npm" --prefer-offline --no-audit
- cd ../../web
- npm ci --cache "$HOME/.cache/woodpecker/webdrop/npm" --prefer-offline --no-audit
- cd ..
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet restore server/WebDrop.slnx
api-contract:
image: bash
commands:
@@ -21,9 +32,9 @@ steps:
core-and-web:
image: bash
depends_on: [prefetch-dependencies]
commands:
- cd packages/core
- npm ci
- npm run typecheck
- npm test
- npm run vectors:check
@@ -31,9 +42,9 @@ steps:
web:
image: bash
depends_on: [prefetch-dependencies]
commands:
- cd web
- npm ci
- npm run typecheck
- npm run lint:css
- npm test
@@ -41,5 +52,6 @@ steps:
server:
image: bash
depends_on: [prefetch-dependencies]
commands:
- dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal --no-restore
+28 -7
View File
@@ -1,6 +1,7 @@
when:
- event: tag
ref: refs/tags/v*
- event: manual
labels:
ci: domain-linux-native
@@ -15,11 +16,25 @@ clone:
lfs: false
steps:
checkout-release-source:
image: bash
commands:
- set -euo pipefail
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
- test -n "$release_tag"
- node scripts/check-api-contract.mjs "$release_tag"
- git fetch --force origin "refs/tags/$release_tag:refs/tags/$release_tag"
- git checkout --detach "refs/tags/$release_tag"
release-checks:
image: bash
commands:
- node scripts/check-api-contract.mjs "$CI_COMMIT_TAG"
- dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
- test -n "$release_tag"
- node scripts/check-api-contract.mjs "$release_tag"
- mkdir -p "$HOME/.cache/woodpecker/webdrop/nuget"
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet restore server/WebDrop.slnx
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal --no-restore
publish-backend:
image: bash
@@ -28,11 +43,17 @@ steps:
from_secret: registry_username
ZOT_PASSWORD:
from_secret: registry_password
ZOT_REGISTRY: zot.oeo.one
ZOT_REGISTRY: 192.168.2.162:5000
commands:
- set -euo pipefail
- printf '%s' "$ZOT_PASSWORD" | buildah login "$ZOT_REGISTRY" --username "$ZOT_USERNAME" --password-stdin
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
- test -n "$release_tag"
- ': "${WOODPECKER_BACKEND_LOCAL_TEMP_DIR:=$HOME/woodpecker/work}"'
- buildah_store="$(dirname "$WOODPECKER_BACKEND_LOCAL_TEMP_DIR")/buildah-cache/webdrop"
- mkdir -p "$buildah_store/storage" "$buildah_store/run"
- 'buildah() { command buildah --root "$buildah_store/storage" --runroot "$buildah_store/run" "$@"; }'
- printf '%s' "$ZOT_PASSWORD" | buildah login --tls-verify=false "$ZOT_REGISTRY" --username "$ZOT_USERNAME" --password-stdin
- trap 'buildah logout "$ZOT_REGISTRY" >/dev/null 2>&1 || true' EXIT
- image="$ZOT_REGISTRY/justme/webdrop-api:$CI_COMMIT_TAG"
- buildah bud --format docker --platform linux/amd64 --tag "$image" --file server/Dockerfile .
- buildah push "$image" "docker://$image"
- image="$ZOT_REGISTRY/justme/webdrop-api:$release_tag"
- buildah bud --layers --network host --format docker --platform linux/amd64 --tag "$image" --file server/Dockerfile .
- buildah push --tls-verify=false "$image" "docker://$image"
+2 -2
View File
@@ -10,7 +10,7 @@
|---|---|
| 协议、API、威胁模型 | 已定稿(`docs/`) |
| `test-vectors/` | 已完成。由 `node:crypto` 独立生成,服务端(.NET)和 `@webdrop/core`(WebCrypto)各自复现 |
| `server/` | 频道与便捷分享 API 已实现并通过 132 个测试;win-x64 与 linux-x64 NativeAOT 发布此前已验证零警告 |
| `server/` | 频道与便捷分享 API 已实现;含独立的可选 `WebDrop.Frontend` 静态资源模块;NativeAOT 发布 |
| `packages/core/` | 已完成并验证。168 个测试,含对着真实服务端的端到端 |
| `web/` | 前端,开发中 |
@@ -45,6 +45,6 @@ webdrop-proj/
├─ docs/
├─ test-vectors/ 协议测试向量,所有实现共用
├─ packages/core/ @webdrop/core,TS,加解密与帧编解码(网页、将来的 CLI 共用)
├─ server/ .NET 10,NativeAOT
├─ server/ .NET 10,NativeAOT;可将 Vite dist 嵌入 WebDrop.Frontend
└─ web/ Vite + React + TypeScript
```
+5 -4
View File
@@ -3,7 +3,7 @@
## 1. 组件
```
浏览器 (React, 独立域名 + SRI) 服务端 (.NET 10, NativeAOT)
浏览器 (React, 独立托管或 API 同源嵌入) 服务端 (.NET 10, NativeAOT)
┌──────────────────────────────┐ ┌─────────────────────────────┐
│ UI: 频道、条目、预览、二维码 │ │ Minimal API + WebSocket/SSE │
│ @webdrop/core (加解密、帧) │ 密文 + 令牌 │ 鉴权: 只比对令牌哈希 │
@@ -15,7 +15,7 @@
(元数据、日志) (磁盘,预留 S3)
```
服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。
服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。可选的 `WebDrop.Frontend` 模块只提供嵌入的静态资源,不访问频道存储或加解密代码。
## 2. 技术栈
@@ -84,8 +84,9 @@ SQLite 存元数据和小帧,磁盘存大帧。
## 5. 部署
- API 与前端使用**不同域名**:前端静态资源可以放在 CDN 或独立静态主机,API 被攻破不影响前端。
- 前端构建产物为所有脚本和样式生成 SRI,发布时公布构建哈希。
- 安全隔离部署时,API 与前端使用**不同域名**:前端静态资源可以放在独立静态主机,API 被攻破不影响前端代码。
- Compose 单机模式可以把 Vite dist 嵌入 `WebDrop.Frontend`,由 API 同源提供页面;此模式简化部署,但 API 主机也是前端代码的信任根。
- 独立托管时,前端资源应使用 HTTPS 发布;若部署 SRI,发布时公布构建哈希。
- 响应头:`Referrer-Policy: no-referrer`、严格 CSP、`X-Content-Type-Options: nosniff`。
- 反向代理不得记录请求头(令牌)和完整 URL。
- 服务端日志不含频道 id 明文,只含截断后的哈希。
+7 -5
View File
@@ -1,6 +1,6 @@
# 本地后端 Docker Compose
Compose 当前只启动 WebDrop API。前端继续使用现有的开发/独立托管方式;本地 Vite 开发服务器通过 `/api/v1` 代理访问 `127.0.0.1:5000`。API 默认只绑定 loopback,SQLite 数据保存在项目的 `./data` 目录。
Compose 当前只启动一个 WebDrop API 容器;构建镜像时会先构建 `web/`,再由独立的 `WebDrop.Frontend` 模块将静态资源嵌入后端。访问 `http://127.0.0.1:5000/` 打开前端,API 使用同源 `/api/v1`。API 默认只绑定 loopback,SQLite 数据保存在项目的 `./data` 目录。
## 启动
@@ -15,10 +15,12 @@ API 地址为 `http://127.0.0.1:5000`。停止服务使用 `docker compose down`
## 前端连接
本机 `npm run dev` 使用 Vite 代理连接默认 API。独立部署前端时,在前端托管层把 `/api/v1` 代理到此 API 地址,这样无需开放 CORS。也可以为跨域托管配置 `WebDrop__CorsOrigins`,值应为明确的前端源列表。
本机开发仍可使用 `cd web; npm run dev`,Vite 会代理 `/api/v1` 到本地 API。独立托管前端时,可单独构建 `web/` 并在前端托管层反向代理 `/api/v1`;跨域直连则配置明确的 `WebDrop__CorsOrigins`。
公网部署时,在 API 前配置 HTTPS 反向代理,并让代理连接 API 的 loopback 端口。不要把未加密 HTTP 直接暴露到公网。只有当该 API 端口仅能由可信代理访问时,才将 `WEBDROP_TRUST_FORWARDED_HEADERS` 设为 `true`。
嵌入模式减少自托管组件,但前端代码与 API 来自同一个部署信任域。若要让 API 节点被攻破时仍不能替换前端,应使用独立域名托管前端。
## 验证码
默认 `CAPTCHA_PROVIDER=none`。启用 Cap 时,在 `.env` 设置:
@@ -34,11 +36,11 @@ CAPTCHA_SECRET=<server-side-secret>
## Woodpecker CI
`.woodpecker/ci.yml` 在 push 和手动运行时执行 API 契约、Core、Web、服务端检查;每次 push 都会构建前端。`.woodpecker/release-backend.yml` 仅响应 `vMAJOR.MINOR.PATCH` SemVer tag;测试通过后用 Linux native agent 的 Buildah 构建并推送同名后端镜像 tag。两者都通过 Woodpecker secret `gitea_clone_webdrop` 从 `https://git.oeo.one` 克隆私有仓库。
`.woodpecker/ci.yml` 在 push 和手动运行时执行 API 契约、Core、Web、服务端检查;每次 push 都会构建前端。`.woodpecker/release-backend.yml` 响应 `vMAJOR.MINOR.PATCH` SemVer tag;也可手动运行并传入 `RELEASE_TAG`(例如 `v0.1.0`)。测试通过后用 Linux native agent 的 Buildah 构建并推送同名后端镜像 tag。两者都通过 Woodpecker secret `gitea_clone_webdrop` 从 `https://git.oeo.one` 克隆公开仓库。
两条流水线固定到 `ci: domain-linux-native` 的 `linux/amd64` Agent;该主机需要 Node 24、.NET 10 和 Buildah。Gitea webhook 走同 Docker 网络中的 `woodpecker-server:8000`,代码 clone 走公网 Git 域名。
两条流水线固定到 `ci: domain-linux-native` 的 `linux/amd64` Agent;该主机需要 Node 24、.NET 10 和 Buildah。release job 在 `WOODPECKER_BACKEND_LOCAL_TEMP_DIR` 对应的持久目录缓存 Buildah 层,Dockerfile 单独缓存 NuGet restore 层。Gitea webhook 走同 Docker 网络中的 `woodpecker-server:8000`,代码 clone 走公网 Git 域名。
后端镜像发布到 `zot.oeo.one/justme/webdrop-api`。在 Woodpecker 仓库配置 tag 事件可用的 secrets `registry_username`、`registry_password`。发布步骤要求 Linux native agent 安装 Buildah;使用已发布镜像时,在 `.env` 中设置 `WEBDROP_IMAGE_TAG`,仓库名已填入 `.env.example`。
后端镜像发布到 `zot.oeo.one/justme/webdrop-api`。Woodpecker Linux agent 通过内网 `192.168.2.162:5000` 访问同一 Zot 仓库,发布需要 Buildah 与 tag/manual 事件可用的 secrets `registry_username`、`registry_password`。使用已发布镜像时,在 `.env` 中设置 `WEBDROP_IMAGE_TAG`,仓库名已填入 `.env.example`。
API 契约版本以 `contracts/api-contract.json` 为准,并同步到 OpenAPI 与后端 `/api/v1/info`。加字段等向后兼容更新递增 minor,兼容性修复递增 patch,破坏性更新递增 major。前端声明支持的契约主版本;后端主版本变化时,前端 CI 契约检查和运行时连接检查都会失败,直到前端同步支持该主版本。
+7 -4
View File
@@ -62,10 +62,13 @@
端到端加密的前提是浏览器执行的是正确的代码。如果同一个被攻破的后端同时下发前端,它可以换成偷 K 的页面。
第一版的缓解:
- 前端静态资源托管在**独立于 API 的域名**,API 节点被攻破不影响前端。
- 所有脚本使用 SRI,构建产物带版本哈希,发布时公布哈希。
- 前端托管方被攻破的风险仍然存在。
隔离部署的缓解:
- 前端静态资源托管在**独立于 API 的域名**,API 节点被攻破不影响前端代码。
- 可由独立托管方校验脚本 SRI 与构建哈希;前端托管方被攻破的风险仍然存在。
嵌入式部署的取舍:
- `WebDrop.Frontend` 将 Vite dist 嵌入后端程序集,让 API 以同源方式提供页面。
- 这只简化部署,不增强前端与后端的隔离。若部署主机或镜像被控制,攻击者仍可替换页面并窃取用户后续输入的密钥。
后续:发布签名加 PWA 校验、CLI 客户端(协议不依赖浏览器,第三方可自行实现)。
+18
View File
@@ -1,13 +1,31 @@
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 \
&& apt-get install -y --no-install-recommends clang zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
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 \
--self-contained true \
--no-restore \
--output /out
RUN find /root/.nuget/packages/sqlitepclraw.lib.e_sqlite3 -path '*/runtimes/linux-x64/native/libe_sqlite3.so' \
-exec cp {} /out/libe_sqlite3.so \; \
+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,140 @@
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)}";
}
}
+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>
+5 -2
View File
@@ -1,7 +1,7 @@
import { supportsApiContract } from '../contract';
export type ApiError = Error & { status?: number; code?: string };
let apiBase = '/api/v1';
let apiBase = new URL('/api/v1', self.location.origin).toString();
let contractCheck: Promise<void> | undefined;
export function setApiBase(value: string): void {
@@ -11,7 +11,10 @@ export function setApiBase(value: string): void {
}
export const fail = (status: number, code: string): ApiError => Object.assign(new Error(code), { status, code });
export const apiPath = (path: string): string => `${apiBase.replace(/\/$/, '')}${path}`;
export const apiPath = (path: string): string => new URL(
`${apiBase.replace(/\/$/, '')}${path}`,
self.location.origin,
).toString();
function ensureApiContract(): Promise<void> {
contractCheck ??= fetch(apiPath('/info'), {
+6 -5
View File
@@ -6,6 +6,7 @@ import { useGlobalPaste } from '@/lib/useGlobalPaste';
import { useChannels } from '@/store/channels';
import { useShares } from '@/store/shares';
import { solveCreationCaptcha } from '@/core-bridge/captcha';
import { errorMessage } from '@/core-bridge/errors';
import { Button } from '@/ui/Button';
import { ChannelList } from './ChannelList';
import { CreateSheet } from './CreateSheet';
@@ -22,7 +23,7 @@ export function Home() {
const [createOpen, setCreateOpen] = useState(false);
const [scanOpen, setScanOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
const [failedMessage, setFailedMessage] = useState('');
const [shareText, setShareText] = useState('');
const [shareFiles, setShareFiles] = useState<File[]>([]);
@@ -32,15 +33,15 @@ export function Home() {
async (p: ClipboardPayload) => {
if (busy || (p.files.length === 0 && !p.text.trim())) return false;
setBusy(true);
setFailed(false);
setFailedMessage('');
try {
const captchaToken = await solveCreationCaptcha();
open(await createDraft(p, captchaToken));
setShareFiles([]);
setShareText('');
return true;
} catch {
setFailed(true);
} catch (error) {
setFailedMessage(errorMessage(error));
return false;
} finally {
setBusy(false);
@@ -71,7 +72,7 @@ export function Home() {
onFilesChange={setShareFiles}
onShare={share}
/>
{failed ? <p role="alert" className={styles.error}>创建分享失败,请重试。</p> : null}
{failedMessage ? <p role="alert" className={styles.error}>创建分享失败:{failedMessage}</p> : null}
<ChannelList kind="share" title="我的分享" onOpen={open} />
+1 -2
View File
@@ -58,8 +58,7 @@
}
.editor:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
outline: none;
}
.editor::placeholder {