10 KiB
架构
1. 组件
浏览器 (React, 独立托管或 API 同源嵌入) 服务端 (.NET 10, NativeAOT)
┌──────────────────────────────┐ ┌─────────────────────────────┐
│ UI: 频道、条目、预览、二维码 │ │ Minimal API + WebSocket/SSE │
│ @webdrop/core (加解密、帧) │ 密文 + 令牌 │ 鉴权: 只比对令牌哈希 │
│ Worker: 流式加解密 │◄───────────►│ 日志: 分配 seq、幂等、推送 │
│ IndexedDB: 发送队列、本地收藏 │ │ BackgroundService: 过期清理 │
│ Service Worker: 流式下载 │ └──────┬───────────┬──────────┘
└──────────────────────────────┘ │ │
SQLite IBlobStore
(元数据、日志) (磁盘,预留 S3)
服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。可选的 WebDrop.Frontend 模块只提供嵌入的静态资源,不访问频道存储或加解密代码。
2. 技术栈
| 层 | 选型 |
|---|---|
| 服务端 | .NET 10,ASP.NET Core Minimal API,原生 WebSocket,Microsoft.Data.Sqlite |
| 前端 | Vite + React + TypeScript,WebCrypto |
| 共享核心 | @webdrop/core(TS,纯函数,无 UI 依赖),将来 CLI 直接复用 |
| 测试 | xUnit、Vitest、Playwright(含断网恢复) |
3. NativeAOT 约束(服务端)
服务端必须能 PublishAot=true 发布成单个原生可执行文件,因此:
| 约束 | 做法 |
|---|---|
| 构建器 | 用 WebApplication.CreateSlimBuilder,不用默认的 CreateBuilder |
| 路由 | 只用 Minimal API,不用 MVC 控制器,不用反射式模型绑定 |
| JSON | 全部通过 System.Text.Json 源生成(JsonSerializerContext),禁用反射序列化 |
| 实时 | 不使用 SignalR(不支持 AOT),用原生 WebSocket 加 SSE,这与协议设计一致 |
| 数据访问 | 不使用 EF Core,用 Microsoft.Data.Sqlite 和手写 SQL,结果映射用显式代码 |
| 依赖注入 | 只用简单的构造函数注入,避免开放泛型和反射式注册 |
| 日志 | 简单控制台日志格式,不用依赖反射的格式化器 |
| 全球化 | InvariantGlobalization=true |
| 库 | 所有项目标记 IsAotCompatible=true,把 IL2026、IL3050 等 AOT 和裁剪警告视为错误 |
| HTTP/3 | CreateSlimBuilder 不含 HTTP/3。服务进程本身暂不内置 QUIC,由反向代理终结 HTTP/3,后端用 HTTP/1.1 或 HTTP/2;WebDrop:EnableHttp3 只是在 /info 里通告。需要内置时再接入 Kestrel 的 QUIC(依赖原生 msquic) |
| 密码学 | 服务端只做 SHA-256 和常量时间比较,System.Security.Cryptography 在 AOT 下可用 |
验证结果(win-x64,.NET 10.0.204):
- Slim builder、Minimal API、原生 WebSocket、SSE、
BackgroundService、Microsoft.Data.Sqlite(随包的 e_sqlite3 原生库,SQLite 3.53)在 NativeAOT 下都能正常运行。 - 完整服务 AOT 发布零警告(
TreatWarningsAsErrors已开启),产物约 12 MB 单文件,另带 1.9 MB 的e_sqlite3.dll。 scripts/smoke.ps1对原生可执行文件跑完整流程(加入、追加、幂等、日志、WebSocket 推送、GATED 下载与上限、错误码、删除,并检查数据目录里没有令牌原值),全部通过。- linux-x64 已验证(WSL Debian,.NET 10.0.300,clang + zlib),并在引入 Cap 验证码之后重新验证:130 个测试在 Linux 上全部通过,AOT 发布零警告(约 14.8 MB),
scripts/smoke.py对 Linux 原生二进制的端到端冒烟通过。@webdrop/core也在 Linux 上对着 Linux 的 AOT 服务端跑过端到端(Node 20 没有全局 WebSocket,其中 3 个 WebSocket 用例被明确跳过,它们在 Windows 上通过)。 - linux-arm64 尚未验证,需要在 arm64 机器或 CI 上跑。每次构建都应执行 AOT 发布加冒烟。
- Windows 上 AOT 发布需要
vswhere.exe在 PATH 里,否则 ILCompiler 找不到 MSVC 链接器。把%ProgramFiles(x86)%\Microsoft Visual Studio\Installer加进 PATH,或在 VS 的开发者命令行里发布。 - 注意
Base64Url.TryDecodeFromChars遇到非法输入会抛出FormatException,不是返回 false。需要不抛异常的解码时用返回OperationStatus的DecodeFromChars(Codec.TryDecodeExact已按此实现)。
4. 存储模型
SQLite 存元数据和小帧,磁盘存大帧。
| 表 | 字段 |
|---|---|
channels |
id PK, mode, settings(JSON), read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes |
shares |
id PK(与频道共用全局 id 空间), settings(JSON), read_hash, owner_hash(封存时清空), creator_endpoint_hash, created_at, expires_at, sealed_at, manifest_item_id, bytes |
seats |
channel_id, endpoint_hash, label BLOB, joined_at;PK(channel_id, endpoint_hash) |
share_visitors |
share_id, endpoint_hash, joined_at;PK(share_id, endpoint_hash),创建者终端不写入此表 |
items |
channel_id, item_id, author_hash, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline, label;PK(channel_id, item_id)。完整与否由 final_index 和 frame_count 推出,是否进入宽限期由 close_deadline 表示 |
share_items |
分享条目的帧状态和下载次数,独立于频道条目 |
log |
channel_id, seq, kind, item_id, frame_index, aux, created_at;PK(channel_id, seq)。aux 存墓碑原因 |
frames |
channel_id, item_id, frame_index, seq, length, digest(SHA-256), inline_blob 或 blob_key;PK(channel_id, item_id, frame_index)。digest 用于幂等比较,不必读 blob |
share_frames |
分享帧的封装、摘要与 blob 引用,独立于频道帧,不进入频道日志 |
IBlobStore:PutAsync、ReadAsync、Delete、DeletePrefix、Enumerate。磁盘实现的键是 {channelHex}/{itemHex}/{index}.{digest16}:
- 目录名用小写十六进制,不用 base64url。频道 id 由客户端选择,而 Windows 文件系统不区分大小写,用 base64url 会让攻击者构造只差大小写的 id,与别人共用目录。
- 键里含内容摘要:并发写入内容不同的同一帧,不会互相覆盖;文件已存在就意味着内容相同,直接跳过。
- 先写临时文件再重命名。小于 64 KiB 的帧直接放 SQLite 的 BLOB。以后可以补 S3 实现,不改上层。
写入顺序:大帧先写 blob,再在单个事务内分配 seq、写日志、写帧、更新条目和频道。崩溃只会留下孤立 blob,不会出现数据库指向不存在文件的情况。冲突或失败时,只有「没有任何帧引用」的 blob 才会被删除。last_seq 在同一事务内递增,seq 因此严格单调且无并发重复。
并发模型:写入由单个 SQLite 连接加锁串行执行(WAL 模式),读取使用独立连接,每次 Read 在显式事务里,看到一致的快照。订阅枢纽 ChannelHub 只传递「有变化」的信号,订阅者被唤醒后自己按游标读日志,所以不存在回放与实时之间漏帧的竞态,也无需为慢客户端缓冲数据。代价是目前只支持单实例部署。
便捷分享与频道共用 ID 唯一性检查,但条目和帧使用独立表。分享不创建席位或日志;封存时在单个事务里清空 owner_hash、记录清单 id 并启动 TTL。
后台服务每 30 秒清理:过期条目、下载达上限且宽限期已过的条目、过期频道,每次产生对应的墓碑并删除密文;每 20 轮额外清理超过 10 分钟的孤立 blob。超出 max_items 的淘汰和新快照取代旧快照,在追加时同步完成。
5. 部署
- 安全隔离部署时,API 与前端使用不同域名:前端静态资源可以放在独立静态主机,API 被攻破不影响前端代码。
- Compose 单机模式可以把 Vite dist 嵌入
WebDrop.Frontend,由 API 同源提供页面;此模式简化部署,但 API 主机也是前端代码的信任根。 - 独立托管时,前端资源应使用 HTTPS 发布;若部署 SRI,发布时公布构建哈希。
- 响应头:
Referrer-Policy: no-referrer、严格 CSP、X-Content-Type-Options: nosniff。 - 反向代理不得记录请求头(令牌)和完整 URL。
- 服务端日志不含频道 id 明文,只含截断后的哈希。
6. 前端要点
- 取出 fragment 后立即
history.replaceState清掉密钥;K 导入为不可导出的CryptoKey。 - 加解密在 Worker 里流式处理,文件不整份进内存。
- 发送队列落在 IndexedDB,恢复网络后自动补发,依赖协议的幂等追加。
- 条目预览在沙箱 iframe 中渲染;raw 内容只当文本展示或下载。
- 下载大文件使用 Service Worker 流式写入,兼容没有 File System Access API 的 iOS Safari。
- 本地「我的频道」:链接和密钥保存在 IndexedDB,只在本机。
7. 阶段划分
| 阶段 | 内容 | 验收 |
|---|---|---|
| 0 | 文档、test-vectors/ |
向量由独立脚本生成并人工复核 |
| 1 | @webdrop/core:密钥派生、封装、加解密、padding、条目装配 |
已完成:Vitest 通过全部向量和负例,并对着真实服务端端到端通过 |
| 2 | 服务端(AOT):频道与便捷分享、帧存储、频道日志、WebSocket/SSE/长轮询、席位、访客名额、清理 | xUnit 通过;目标平台 AOT 发布并冒烟通过 |
| 3 | 前端:创建频道、文本和 JSON 实时同步、断线重连、发送队列、「我的频道」 | Playwright:双浏览器内容一致,断网恢复后无丢失无重复,抓包无明文和 K |
| 4 | 文件:流式加解密、GATED 拉取、下载计数、预览、粘贴和拖拽 | 100 MB 文件往返一致,下载计数正确 |
| 5 | GB 级文件:并行上传和断点续传 | 中途断网后续传成功,内存占用平稳 |
| 6 | PWA、Web Share Target、二维码和短配对码(PAKE) | 手机端走通 |
| 7 | 可选账号、配额;CLI | 另行设计 |
每个阶段开始前,先更新 protocol.md 和测试向量,再改代码。
8. 验收时的检查清单
dotnet test、vitest、Playwright 全部通过。- 对 SQLite 文件和 blob 目录 grep 明文关键字,必须查不到。
- 服务端日志中没有 K、令牌和频道 id 明文。
- 抓包确认请求里没有 K 和明文。
- AOT 发布产物能在没有 .NET 运行时的机器上运行。