Files
WebDrop/docs/architecture.md
justme 12980ba6fe
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
docs: describe login-only spaces and the 8.0.0 contract [CI]
2026-10-16 06:46:16 +08:00

146 lines
14 KiB
Markdown

# 架构
公共业务数据(空间设置、成员、设备、邀请、默认值和部署限制)由后端 DTO 和服务端响应定义;前端类型由构建工具生成。本地资源保险库只携带访问凭据与离线副本,不覆盖在线响应中的设置。在线刷新不改变资源的用户修改时间,也不产生删除标记;只在网络故障时回退缓存。
账户昵称头像、空间、频道、分组、文件夹和文件归属由后端管理。空间结构变更经服务端验证关联和权限后持久化,再通知设备重新拉取。新空间和初始“常规”频道在一个事务中创建。
消息、文件内容和文件名称等资源元数据由客户端加密,后端保存密文和加密密钥包。账号保险库保存解密凭据,不再作为个人资料或空间结构的数据源。
不提供旧数据迁移或导入接口。新空间结构由后端直接创建和管理,客户端不解码或重放旧结构控制记录。
## 1. 组件
账号、设备、频道、thread 与内容加密的统一边界见 [account-channel-architecture.md](account-channel-architecture.md)。该文档明确账号成员与设备会话分离、按账号领取频道密钥包及加密文件目录。
```
浏览器 (React, 独立托管或 API 同源嵌入) 服务端 (.NET 10, NativeAOT)
┌──────────────────────────────┐ ┌─────────────────────────────┐
│ UI: 频道、条目、预览、二维码 │ │ Minimal API + WebSocket/SSE │
│ @webdrop/core (加解密、帧) │ 密文 + 令牌 │ 鉴权: 只比对令牌哈希 │
│ Worker: 流式加解密 │◄───────────►│ 日志: 分配 seq、幂等、推送 │
│ IndexedDB: 发送队列、本地收藏 │ │ BackgroundService: 过期清理 │
│ OPFS: 逐块写入临时文件 │ └──────┬───────────┬──────────┘
└──────────────────────────────┘ │ │
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、登录/加入密码的加盐 PBKDF2 与常量时间比较,全部兼容 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. 存储模型
应用契约 2 使用 `DataDir/v2/webdrop.db` 和 `DataDir/v2/blobs`,仅包含当前表结构,不维护旧 schema/旧账号保险库/旧频道认领/旧明文负载的迁移或兼容模型。开发数据可以重新创建,历史目录不自动导入。浏览器采用新的按账号/访客隔离的数据库命名空间。
SQLite 存元数据和小帧,磁盘存大帧。
| 表 | 字段 |
|---|---|
| `channels` | `id` PK, `mode`(内容加密选项), `creator_account`, `settings`, 能力令牌哈希, 加入密码哈希/salt, 时间、序号、字节数 |
| `channel_members` | 频道 ID + username,频道内 owner/member 角色、加入时间;创建者自动为 owner |
| `channel_invitations` | 频道 ID + username、客户端加密的邀请 payload、时间 |
| `channel_join_denials` | 被移除的账号或设备标识,再次邀请账号时清除账号禁入 |
| `accounts/account_sessions` | 登录 salt、登录凭据慢哈希及独立 salt、邀请公钥、保险库密文/revision;随机 session token 的 SHA-256 与过期时间 |
| `shares` | `id` PK(与频道共用全局 id 空间), `settings`(JSON), `read_hash`, `owner_hash`(封存时清空), `creator_endpoint_hash`, `created_at`, `expires_at`, `sealed_at`, `manifest_item_id`, `bytes` |
| `channel_members` | 频道成员(账号):`channel_id`, `username`, `role`, `joined_at`。v8 起不再有访客席位,旧库里的 `seats` 表在启动时丢弃 |
| `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 内容只当文本展示或下载。
- 下载大文件优先使用 OPFS 流式写入临时文件,认证完成后提供文件;不支持 OPFS 时回退 Blob,限制 64 MiB。
- 首页先注册/登录;频道链接只对登录账号有效(未登录先登录),便捷分享链接任何人可读。创建与管理在服务端绑定账号,成员人数按账号去重,设备只是账号的属性(账号设备与登录 session 在 `/accounts/devices` 管理)。所有频道写入都检查账号会话、频道角色、成员名额和能力权限。
- IndexedDB 按账号分库;切换时中止后台订阅并清空内存。保险库同步私钥、资源凭据和移除墓碑;设备 S、设备标签、草稿内容、缓存、原始文件和 outbox 保留本机。新设备生成独立 S 后重新加入。
- 空间统一管理成员、权限与密钥,后端管理频道、分组和目录。资源日志仅保存内容及资源元数据;CONTROL 资源元数据记录随空间删除。
## 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 | 账号登录、客户端加密资源保险库 | 已实现客户端派生口令 verifier/保险库密钥、SQLite opaque vault、资源同步;口令丢失时明确告知无法恢复 |
| 8 | 空间、频道、分组、文件夹与聊天 | 结构和作者归属由后端管理,客户端加解密资源并渲染后端快照 |
每个阶段开始前,先更新 protocol.md 和测试向量,再改代码。
## 8. 验收时的检查清单
- `dotnet test`、`vitest`、Playwright 全部通过。
- 对 SQLite 文件和 blob 目录检查资源内容与文件名明文关键字,必须查不到;账号资料和业务结构按设计对后端可见。
- 服务端日志中没有 K、令牌和频道 id 明文。
- 抓包确认资源上传请求不包含 K 或资源明文;账户资料和结构请求使用后端业务接口。
- AOT 发布产物能在没有 .NET 运行时的机器上运行。
界面术语采用「空间 → 频道分组 → 频道 → 文件夹/聊天」。内部资源 Channel 与帧元数据 threadId 保留,分别映射空间/频道;分组和目录由后端结构接口管理,不引入新密钥体系。