chore(repo): initialize WebDrop monorepo
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
commit
53768a195d
39 files changed
+9063
No files matched your search
+191
@@ -0,0 +1,191 @@
|
||||
# API v1
|
||||
|
||||
基础路径 `/api/v1`。所有请求和响应的二进制数据格式见 [protocol.md](protocol.md)。前端托管在独立域名,服务端需要配置 CORS 白名单。
|
||||
|
||||
## 1. 鉴权与通用头
|
||||
|
||||
| 头 | 说明 |
|
||||
|---|---|
|
||||
| `X-WD-Role` | `read` / `write` / `owner`,指明 `Authorization` 里的令牌属于哪种角色 |
|
||||
| `Authorization` | `Bearer <base64url(令牌)>` |
|
||||
| `X-WD-Endpoint` | `base64url(S)`,终端密钥,用于席位 |
|
||||
| `X-WD-Cap` | 创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11) |
|
||||
| `X-WD-Label` | raw 模式条目标签,≤255 B |
|
||||
| `X-WD-Max-Downloads` | 第 0 帧上带,条目的下载上限,0 为不限 |
|
||||
|
||||
raw 模式的读请求不需要令牌,但仍需要 `X-WD-Endpoint`。
|
||||
|
||||
WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, wd.role.<role>, wd.auth.<base64url(令牌)>, wd.ep.<base64url(S)>`,服务端回选 `wd.v1`。不要放进 URL,否则会被代理记录。
|
||||
|
||||
## 2. 权限矩阵
|
||||
|
||||
| 操作 | 需要 |
|
||||
|---|---|
|
||||
| 读日志、拉取条目 | `read`(enc)或知道 id(raw),并占有席位 |
|
||||
| 追加帧 | `write`(`members_can_upload` 为 true 时)或 `owner` |
|
||||
| 删除条目 | `owner`;`delete_policy=own` 时终端哈希等于条目作者;`anyone` 时任意已入席终端 |
|
||||
| 改设置、删频道、看和释放席位 | `owner` |
|
||||
|
||||
## 3. 接口
|
||||
|
||||
### `GET /api/v1/info`
|
||||
返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。
|
||||
|
||||
### `POST /api/v1/channels`
|
||||
创建频道。服务端启用验证码时需要 `X-WD-Cap`;验证服务不可用时返回 `503 captcha_unavailable`,与「用户没通过验证」(`428 captcha_required`)区分。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<channel_id>",
|
||||
"mode": "enc",
|
||||
"settings": { "seats": 2, "members_can_upload": true, "delete_policy": "own",
|
||||
"ttl_seconds": 86400, "max_items": 1000, "file_max_downloads": 10 },
|
||||
"read_hash": "<sha256(T_read)>",
|
||||
"write_hash": "<sha256(T_write)>",
|
||||
"owner_hash": "<sha256(T_owner)>"
|
||||
}
|
||||
```
|
||||
|
||||
raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创建者的终端(`X-WD-Endpoint`)自动入席。返回 `201`,响应体与「读取频道信息」相同(`ChannelInfo`)。
|
||||
|
||||
### `GET /api/v1/channels/{id}`
|
||||
返回 `ChannelInfo`:设置、模式、席位占用、`oldest_seq`、`last_seq`、时间。**不占席位**,任意角色可调用,用于加入前预览和核对模式。
|
||||
|
||||
### `POST /api/v1/channels/{id}/seats`
|
||||
加入频道,占用一个席位。幂等:同一终端重复调用返回同一席位。席位已满返回 `403 seats_full`。除本接口和上面的「读取频道信息」外,其余频道接口都要求终端已入席,否则返回 `403 not_joined`。
|
||||
|
||||
### `PATCH /api/v1/channels/{id}/settings`
|
||||
所有者修改设置。`ttl_seconds` 的修改从现在起算。
|
||||
|
||||
### `DELETE /api/v1/channels/{id}`
|
||||
所有者删除整个频道和全部密文。
|
||||
|
||||
### `POST /api/v1/channels/{id}/frames`
|
||||
追加一帧。请求体是完整封装(`application/octet-stream`)。
|
||||
|
||||
| 响应 | 含义 |
|
||||
|---|---|
|
||||
| `201 {"seq"}` | 新追加 |
|
||||
| `200 {"seq"}` | 幂等重发,返回原 seq |
|
||||
| `409` | 同一 `(item_id, frame_index)` 内容不同 |
|
||||
| `403` | 无权、席位已满、模式或 ENC 标志不符 |
|
||||
| `413` | 超出限制 |
|
||||
| `429` | 限流 |
|
||||
|
||||
### `GET /api/v1/channels/{id}/log?from=<seq>&limit=&wait=<秒>`
|
||||
按 seq 拉取日志,返回记录的线格式拼接(`application/octet-stream`)。`wait` 大于 0 时为长轮询,最长 25 秒。满足 `0 < from < oldest_seq` 时返回 `410 {"error":"gone","oldest_seq":N}`,客户端必须从 `from=0` 全量同步并丢弃本地已不存在的条目(不是从 `oldest_seq` 读起,见 protocol §8)。`from=0` 永远允许。
|
||||
|
||||
### `GET /api/v1/channels/{id}/ws?from=<seq>`
|
||||
WebSocket 订阅(降级链的第一级)。服务端向下推送:
|
||||
- 二进制消息:一条日志记录。
|
||||
- 文本消息(JSON 控制消息):`{"t":"hb","seq":N}` 心跳,`{"t":"settings"}` 设置变更,`{"t":"seats"}` 席位变更。
|
||||
|
||||
上传仍走 HTTP `POST`,便于幂等重试。
|
||||
|
||||
### `GET /api/v1/channels/{id}/events?from=<seq>`
|
||||
SSE 订阅(降级链的第二级)。记录以 base64url 放在 `data:` 中,`id:` 是 seq,断线后用 `Last-Event-ID` 或 `from` 续传。由于 `EventSource` 不能设置头,前端用 `fetch` 流式读取。
|
||||
|
||||
### 降级与重连
|
||||
WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20 秒,45 秒收不到任何消息即判定死链。重连使用指数退避(1 秒到 30 秒)加抖动,并携带 `from=last_seq+1`。
|
||||
|
||||
### `GET /api/v1/channels/{id}/items/{item_id}/frames?from=<index>`
|
||||
拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载,`from>0` 视为续传不计数。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
|
||||
|
||||
### `DELETE /api/v1/channels/{id}/items/{item_id}`
|
||||
按 `delete_policy` 授权后删除条目,并在日志中追加 tombstone。
|
||||
|
||||
### 席位
|
||||
- `GET /api/v1/channels/{id}/seats`:所有者查看(终端哈希、加密设备名、加入时间)。
|
||||
- `PUT /api/v1/channels/{id}/seats/me`:终端上传加密设备名(≤256 B)。
|
||||
- `DELETE /api/v1/channels/{id}/seats/{endpoint_hash}`:所有者释放席位。
|
||||
|
||||
## 4. 便捷分享接口
|
||||
|
||||
便捷分享是与频道并列的另一种资源,语义见 [protocol.md §15](protocol.md)。**没有 WebSocket、SSE、日志、席位和成员上传**。接口前缀 `/api/v1/shares`,只支持 enc 模式,鉴权头只用 `read` 和 `owner` 两种角色(`write` 会被拒绝为 `bad_request`)。
|
||||
|
||||
| 接口 | 角色 | 阶段 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `POST /shares` | 无(需验证码) | | 创建**草稿**。可带 `X-WD-Endpoint`:这个终端是创建者,不占访客名额 |
|
||||
| `PATCH /shares/{id}/settings` | owner | 草稿 | 改有效期、访客上限、默认下载次数 |
|
||||
| `POST /shares/{id}/frames` | owner | 草稿 | 追加帧。格式与频道的帧完全相同;不允许 SNAPSHOT 标志 |
|
||||
| `DELETE /shares/{id}/items/{item_id}` | owner | 草稿 | 移除条目 |
|
||||
| `DELETE /shares/{id}` | owner | 草稿 | 删除整个草稿 |
|
||||
| `POST /shares/{id}/finish` | owner | 草稿 | **封存**,一次性、不可撤销 |
|
||||
| `GET /shares/{id}` | read | 已封存 | 分享信息和每个文件的剩余下载次数(owner 在草稿阶段也可读) |
|
||||
| `GET /shares/{id}/manifest` | read | 已封存 | 清单条目的封装字节;**首次读取登记访客** |
|
||||
| `GET /shares/{id}/items/{item_id}/frames?from=<index>` | read | 已封存 | 下载文件,`from=0` 计一次下载;**首次读取同样登记访客** |
|
||||
|
||||
### `POST /api/v1/shares`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<share_id>",
|
||||
"read_hash": "<sha256(T_read)>",
|
||||
"owner_hash": "<sha256(T_owner)>",
|
||||
"settings": { "ttl_seconds": 86400, "max_visitors": 0, "file_max_downloads": 10 }
|
||||
}
|
||||
```
|
||||
|
||||
需要验证码(`X-WD-Cap`,与创建频道相同的规则),并受创建限流约束。返回 `201 ShareInfo`。草稿的 `expires_at` 是创建后 24 小时(服务端配置 `WebDrop:ShareDraftTtlSeconds`);`ttl_seconds` 是**封存之后**的有效期,封存时才开始计时。
|
||||
|
||||
| 设置 | 默认 | 范围 |
|
||||
|---|---|---|
|
||||
| `ttl_seconds` | 86400 | 60 ~ 604800 |
|
||||
| `max_visitors` | 0(不限) | 0 ~ 10000 |
|
||||
| `file_max_downloads` | 10 | 0(不限)或更大 |
|
||||
|
||||
### `POST /api/v1/shares/{id}/frames`
|
||||
|
||||
请求体是完整的帧封装,响应沿用频道的 `201`/`200 {"seq"}` 结构。便捷分享没有日志游标,这里的 `seq` 是该帧的 `frame_index`,用于确认追加与幂等重发。内容冲突返回 `409 conflict`,超限返回 `413 too_large`。额外规则:SNAPSHOT 标志返回 `400 bad_request`;分享已封存返回 `409 sealed`。第 0 帧可带 `X-WD-Max-Downloads` 设置该文件的下载次数上限。
|
||||
|
||||
### `POST /api/v1/shares/{id}/finish`
|
||||
|
||||
```json
|
||||
{ "manifest_item_id": "<b64url>" }
|
||||
```
|
||||
|
||||
按 protocol §15.5 校验后封存。成功返回 `200 ShareInfo`(此时 `state` 为 `sealed`)。清单条目不存在、不是单帧非 GATED 的完整条目,或草稿里还有没传完的条目,返回 `400 bad_request`。已封存的分享再次调用返回 `409 sealed`。
|
||||
|
||||
### `GET /api/v1/shares/{id}`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "…", "state": "sealed",
|
||||
"expires_at": 1700000000000, "created_at": 1699990000000, "sealed_at": 1699999999000,
|
||||
"manifest_item_id": "…",
|
||||
"settings": { "ttl_seconds": 86400, "max_visitors": 0, "file_max_downloads": 10 },
|
||||
"items": [ { "item_id": "…", "bytes": 12345, "frames": 3, "max_downloads": 5, "downloads_left": 4 } ]
|
||||
}
|
||||
```
|
||||
|
||||
`downloads_left` 为 `null` 表示不限。响应**永远不包含访客人数**。草稿对读角色返回 `404 not_found`。
|
||||
|
||||
### `GET /api/v1/shares/{id}/manifest`
|
||||
|
||||
返回清单条目的完整封装(`application/octet-stream`),必须带 `X-WD-Endpoint`。登记访客;访客名额已满且这个终端未登记时返回 `403 visitors_full`。
|
||||
|
||||
### `GET /api/v1/shares/{id}/items/{item_id}/frames`
|
||||
|
||||
与频道的 GATED 拉取一致:`from=0` 计一次下载,`from>0` 视为续传不计数;达到上限后返回 `410 gone`,5 分钟宽限期内已开始的拉取可继续续传。必须带 `X-WD-Endpoint`,同样会登记访客。
|
||||
|
||||
## 5. 错误
|
||||
|
||||
错误响应体是 JSON `{"error": "<code>"}`,不回显任何用户提交的内容。
|
||||
|
||||
| code | HTTP | 含义 |
|
||||
|---|---|---|
|
||||
| `bad_request` | 400 | 封装格式或参数错误 |
|
||||
| `unauthorized` | 401 | 令牌缺失或错误 |
|
||||
| `forbidden` | 403 | 角色不够 |
|
||||
| `not_joined` | 403 | 终端未入席,或席位已被所有者释放 |
|
||||
| `visitors_full` | 403 | 便捷分享的访客名额已满,且这个终端未登记 |
|
||||
| `sealed` | 409 | 便捷分享已封存,不能再修改;封存后再用所有者令牌发请求也是这个错误 |
|
||||
| `seats_full` | 403 | 席位已满 |
|
||||
| `mode_mismatch` | 403 | 帧的 ENC 标志与频道模式不符 |
|
||||
| `not_found` | 404 | 频道或条目不存在,或已过期 |
|
||||
| `conflict` | 409 | 幂等冲突 |
|
||||
| `gone` | 410 | 已删除、下载达上限,或 `0 < from < oldest_seq`(需要从 0 全量同步) |
|
||||
| `too_large` | 413 | 超出限制 |
|
||||
| `captcha_required` | 428 | 需要验证码:缺少 `X-WD-Cap`,或 token 无效、已用过 |
|
||||
| `captcha_unavailable` | 503 | 验证码服务暂时不可用(服务端到 Cap 的调用失败),可稍后重试 |
|
||||
| `rate_limited` | 429 | 限流 |
|
||||
@@ -0,0 +1,123 @@
|
||||
# 架构
|
||||
|
||||
## 1. 组件
|
||||
|
||||
```
|
||||
浏览器 (React, 独立域名 + SRI) 服务端 (.NET 10, NativeAOT)
|
||||
┌──────────────────────────────┐ ┌─────────────────────────────┐
|
||||
│ UI: 频道、条目、预览、二维码 │ │ Minimal API + WebSocket/SSE │
|
||||
│ @webdrop/core (加解密、帧) │ 密文 + 令牌 │ 鉴权: 只比对令牌哈希 │
|
||||
│ Worker: 流式加解密 │◄───────────►│ 日志: 分配 seq、幂等、推送 │
|
||||
│ IndexedDB: 发送队列、本地收藏 │ │ BackgroundService: 过期清理 │
|
||||
│ Service Worker: 流式下载 │ └──────┬───────────┬──────────┘
|
||||
└──────────────────────────────┘ │ │
|
||||
SQLite IBlobStore
|
||||
(元数据、日志) (磁盘,预留 S3)
|
||||
```
|
||||
|
||||
服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。
|
||||
|
||||
## 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 与前端使用**不同域名**:前端静态资源可以放在 CDN 或独立静态主机,API 被攻破不影响前端。
|
||||
- 前端构建产物为所有脚本和样式生成 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 运行时的机器上运行。
|
||||
@@ -0,0 +1,173 @@
|
||||
# 前端架构
|
||||
|
||||
`web/` 的工程文档。视觉与交互见 [ui-design.md](ui-design.md),协议见 [protocol.md](protocol.md),接口见 [api.md](api.md)。
|
||||
|
||||
## 1. 技术栈
|
||||
|
||||
| 项 | 选择 | 说明 |
|
||||
|---|---|---|
|
||||
| 构建 | Vite | 纯客户端 SPA,不做 SSR |
|
||||
| 语言 | TypeScript,`strict` + `noUncheckedIndexedAccess` | |
|
||||
| UI | React 19.3(稳定版,含 `ViewTransition`、`Activity`) | 不使用 canary |
|
||||
| 状态 | Zustand | 按领域拆 store |
|
||||
| 本地存储 | `idb`(IndexedDB) | 发送队列、「我的频道」 |
|
||||
| 样式 | 原生 CSS + CSS 变量 + CSS Modules | 无 Tailwind、无 CSS-in-JS |
|
||||
| 路由 | 自写极简导航(`app/navigation.ts`) | 只有两三个路由,且需统一挂 view transition |
|
||||
| 测试 | Vitest(单元)、Playwright(端到端) | |
|
||||
| 二维码生成 | 自写编码器 `lib/qr.ts`(字节模式、纠错 M、版本 1–10,最多 213 字节),与 Python `qrcode` 逐模块对比验证,见 `lib/qr.test.ts` |
|
||||
| 扫码 | 浏览器自带 `BarcodeDetector` + `getUserMedia`,零依赖;不支持时退回粘贴链接 |
|
||||
| 加解密 | `@webdrop/core`,经 `core-bridge` 调用,在 Worker 中运行 | |
|
||||
|
||||
**依赖策略**:新增运行时依赖需要书面理由(解决什么、为何不能自写、体积)。不引入 UI 组件库、动画库、图标库、日期库。依赖越少,越容易审计;前端是整个方案的信任根。
|
||||
|
||||
## 2. 目录与分层
|
||||
|
||||
```
|
||||
web/src/
|
||||
├─ app/ 入口、Provider、导航、主题
|
||||
├─ styles/ tokens.css(唯一的颜色来源)、base.css
|
||||
├─ ui/ 无业务的基础组件、图标(Button、Sheet、Menu、Toast)
|
||||
├─ features/ 按功能垂直切分:home / channel / join / item / settings
|
||||
├─ store/ 领域状态与事件流 reducer(纯逻辑)
|
||||
├─ core-bridge/ 对 @webdrop/core、Worker、网络传输的封装
|
||||
└─ lib/ 与框架无关的小工具(链接解析、退避、格式化)
|
||||
```
|
||||
|
||||
依赖方向**单向向下**:
|
||||
|
||||
```
|
||||
app → features → ui
|
||||
│
|
||||
└──→ store → core-bridge → @webdrop/core
|
||||
lib 可被任何层使用,lib 不依赖其他层
|
||||
```
|
||||
|
||||
| 规则 | 说明 |
|
||||
|---|---|
|
||||
| `ui/` 不依赖 `features/`、`store/` | 基础组件无业务 |
|
||||
| `features/` 之间不互相导入 | 需共享就上移到 `ui/` 或 `store/`;唯一例外:`channel/`、`home/` 是组合层,可导入 `item/`、`scan/`、`channel/` 里的弹层 |
|
||||
| 组件不直接调用网络或加解密 | 只通过 `store/` 的 action 与 selector |
|
||||
| 协议逻辑只在 `store/`、`core-bridge/`、`lib/` | 全部用纯函数,可脱离 DOM 测试 |
|
||||
| 不使用 barrel(`index.ts` 汇总导出) | 直接从具体文件导入,利于 tree-shaking 与追踪 |
|
||||
|
||||
## 3. 状态模型
|
||||
|
||||
频道是追加日志,**条目状态由日志推导,不单独存储可变副本**。
|
||||
|
||||
| store | 持有 | 说明 |
|
||||
|---|---|---|
|
||||
| `channels` | 本机已保存的频道、当前会话的频道元信息 | 持久化到 IndexedDB |
|
||||
| `items`(按频道) | 由帧装配出的条目 Map(`item_id → Item`) | 按 `item_id` 建 Map,不用数组 `find` |
|
||||
| `transport` | 连接状态、`last_seq`、降级级别 | |
|
||||
| `queue` | 待发送帧(已加密的字节) | 持久化到 IndexedDB |
|
||||
|
||||
条目用判别联合,所有 `switch` 必须穷尽:
|
||||
|
||||
```ts
|
||||
type ItemState =
|
||||
| { kind: 'receiving' }
|
||||
| { kind: 'complete' }
|
||||
| { kind: 'incomplete' }
|
||||
| { kind: 'tampered' }
|
||||
| { kind: 'removed'; reason: 'deleted' | 'expired' | 'evicted' | 'limit' };
|
||||
```
|
||||
|
||||
组件订阅**派生出的最小值**(如 `isOnline`、某条目的 `state.kind`),不订阅整个 store。
|
||||
|
||||
## 4. 传输与重连
|
||||
|
||||
遵循 [api.md §降级与重连](api.md):WebSocket → SSE → 长轮询;心跳 20 秒,45 秒无消息判死;退避 1–30 秒加抖动;重连携带 `from=last_seq+1`;收到 `410` 则**从 `from=0` 全量同步**,并丢弃本地已不存在的条目(从 `oldest_seq` 读会漏掉 seq 更小的存活条目,见 api.md)。
|
||||
|
||||
- 传输层是 `core-bridge/` 内的状态机,对 `store/` 只暴露「记录流」和「连接状态」。
|
||||
- 上传永远走 HTTP `POST`,依赖协议幂等重发,不需要前端去重逻辑之外的补偿。
|
||||
- **队列里持久化的是已加密的帧**。重试必须发送完全相同的字节(protocol §5 的 IV 规则),前端任何位置都不得对同一 `(item_id, frame_index)` 重新加密。
|
||||
|
||||
## 5. 密钥与安全处理
|
||||
|
||||
| 规则 | 说明 |
|
||||
|---|---|
|
||||
| 取出 fragment 后立即 `history.replaceState` | 在任何异步代码之前完成 |
|
||||
| K 导入为不可导出的 `CryptoKey` | 内存中不保留原始字节 |
|
||||
| 降级防御 | 链接含 `k` 就只接受 enc 且认证通过的帧 |
|
||||
| 不把密钥、令牌、明文写入 `console`、错误上报、URL、`localStorage` | 「我的频道」只存 IndexedDB |
|
||||
| 条目预览在沙箱 iframe 渲染;raw 内容只当纯文本显示或下载 | 无内联渲染,不使用 `dangerouslySetInnerHTML` |
|
||||
| 严格 CSP,所有脚本样式带 SRI,不使用内联脚本 | 与 architecture §5 一致 |
|
||||
| 不引入任何第三方脚本、统计、字体 CDN | 字体自托管 |
|
||||
|
||||
## 6. 路由与导航
|
||||
|
||||
- 路由只有:`/`、`/c/:id`、`/c/:id/settings`。
|
||||
- **所有跳转经 `app/navigation.ts` 的 `navigate(to, direction)`**,内部用 `startTransition` + `addTransitionType` 触发 view transition。禁止在组件中直接 `history.pushState` 或 `location.assign`。
|
||||
- 浏览器后退/前进不会触发 view transition(`popstate` 同步),这是已知限制,接受其无动画。
|
||||
- 页面级方向性 `ViewTransition` 写在**页面组件**里,不写在布局里;用一个共享的 `DirectionalTransition` 包装。
|
||||
|
||||
## 7. 动画实现要点
|
||||
|
||||
遵循 `react-view-transitions` 技能的约束,并与 [ui-design.md §9](ui-design.md) 一致:
|
||||
|
||||
- 动画 CSS 取自技能的配方(`--duration-*`、`fade`、`slide`、`nav-forward/back`),集中放在 `styles/transitions.css`,不在组件里写动画 CSS。
|
||||
- `ViewTransition` 一律 `default="none"`,显式列出 `enter` / `exit`,成对出现。
|
||||
- Suspense 的揭示动画用简单字符串属性,不用类型映射。
|
||||
- 共享元素的 `name` 全局唯一(如 `item-${id}`),不在可同时挂载两处的复用组件里写具名 VT。
|
||||
|
||||
## 8. 性能约定
|
||||
|
||||
遵循 `react-best-practices` 技能中适用于纯客户端 SPA 的部分:
|
||||
|
||||
| 方面 | 约定 |
|
||||
|---|---|
|
||||
| 首屏体积 | 按路由 `lazy` 拆分;二维码、预览、设置等重组件动态加载;**禁止 barrel 导入** |
|
||||
| 重渲染 | 订阅最小派生值;事件处理中才用的状态用 ref 或 `getState()`;高频值(上传进度)用 ref 或独立细粒度 store,不触发整列表重渲染 |
|
||||
| 派生状态 | 渲染期计算,不用 effect 同步 |
|
||||
| 列表 | `content-visibility: auto`;条目 key 为 `item_id` |
|
||||
| 条件渲染 | 用三元,不用 `&&` 渲染可能为 `0` 的值 |
|
||||
| 重计算 | 加解密、装配、哈希一律在 Worker,主线程不处理大文件 |
|
||||
| 非紧急更新 | 用 `startTransition` / `useDeferredValue` |
|
||||
| 模块级单例 | 只做一次的初始化(Worker、IndexedDB 连接)用惰性初始化,避免重复 |
|
||||
|
||||
`server-*` 与 Next.js 相关规则不适用本项目。
|
||||
|
||||
## 9. 可维护性约定
|
||||
|
||||
1. 一个文件一个职责;组件文件约 150 行为上限,超出就拆。
|
||||
2. 不预先抽象。同样的代码第二次出现才抽,第三次必须抽。
|
||||
3. 导出面最小化:不被其他文件使用的符号不导出。
|
||||
4. 注释只写「为什么」,尤其是协议约束与安全理由,并指向文档章节(如 `// protocol §5: 重试必须发相同字节`)。
|
||||
5. 颜色、间距、圆角、阴影都不得写死在组件里,一律走 `tokens.css`。
|
||||
6. 改动协议相关行为前,先改 `docs/`,再改代码(与 architecture §7 一致)。
|
||||
7. 每个 `store/` 与 `lib/` 的模块有对应的 Vitest;每个用户可见流程有 Playwright 用例。
|
||||
|
||||
## 10. 测试
|
||||
|
||||
| 层 | 工具 | 内容 |
|
||||
|---|---|---|
|
||||
| `lib/`、`store/` | Vitest | 条目装配、状态推导、退避、队列、链接解析(含降级防御的负例) |
|
||||
| `core-bridge/` | Vitest | 传输降级、`410` 重同步、断线续传(用假传输) |
|
||||
| 界面流程 | Playwright | 双浏览器内容一致;断网恢复后无丢失无重复;抓包无明文和 K |
|
||||
| 视觉 | Playwright 截图 | 亮暗两套、手机与桌面宽度、reduced-motion、无 `backdrop-filter` 回退 |
|
||||
|
||||
## 11. 阶段(对应 architecture §7)
|
||||
|
||||
| 阶段 | 前端内容 |
|
||||
|---|---|
|
||||
| 3 | 设计系统与骨架(静态,假数据) → 创建、加入、文本与 JSON 实时同步、重连、发送队列、「我的频道」 |
|
||||
| 4 | 文件:流式上传下载、GATED 拉取、下载计数、预览、粘贴与拖放 |
|
||||
| 6 | PWA、Web Share Target、二维码与短配对码 |
|
||||
|
||||
在 `@webdrop/core` 未就绪前,`core-bridge/` 按 protocol.md 定义的接口先放适配层和假实现,真实实现就绪后替换,组件与 store 不改。
|
||||
|
||||
## 12. 与 `@webdrop/core` 对接备忘
|
||||
|
||||
`core-bridge/api.ts` 是前端与后端的唯一边界,现在由 `mock.ts` 实现。接 `@webdrop/core`([packages/core/README.md](../packages/core/README.md))时对应关系:
|
||||
|
||||
| 前端需要 | core / 协议 |
|
||||
|---|---|
|
||||
| 新建频道或分享 | `ChannelSession.create({ mode, settings, endpointSecret })` → `POST /channels`;分享的 `settings` 为 `members_can_upload=false`、`delete_policy=owner_only`,并设 `seats`、`ttl_seconds`、`file_max_downloads` |
|
||||
| 草稿阶段调整设置 | `PATCH /channels/{id}/settings`(需所有者令牌) |
|
||||
| **完成分享** | 客户端丢弃所有者密钥 A:用成员链接重新 `ChannelSession.open(...)`,此后 `session.can('owner')` 为 false。丢弃是客户端的自觉行为,服务端不知道;若要强制,需后端提供「封存」让服务端作废 `owner_hash`(属于后端与协议改动,前端只提请求) |
|
||||
| 文件下载次数 | 每个条目的 `maxDownloads` 会强制 GATED,并带 `X-WD-Max-Downloads`,**必须随第 0 帧发送**。文本不要设,否则读文本也要拉取 |
|
||||
| 内联渲染不烧次数 | GATED 条目每次 `GET /items/{id}/frames?from=0` 计一次下载;自动加载只用于非 GATED(见 ui-design §6.5) |
|
||||
| 终端密钥 | `session.endpointSecret` 必须持久化到 IndexedDB,否则每次打开都占一个新席位。原型暂未做 |
|
||||
| 创建频道的验证码 | 读 `GET /info` 的 `captcha`,用 Cap 求解后放进 `X-WD-Cap` |
|
||||
| 校验失败 | reader 的 `rejected` 事件不能忽略,至少在界面提示「内容校验失败」 |
|
||||
| Worker | `ChannelSession` 不能跨线程,在 Worker 里用链接和 `endpointSecret` 重建 |
|
||||
@@ -0,0 +1,860 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: WebDrop API
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: /api/v1
|
||||
|
||||
tags:
|
||||
- name: server
|
||||
- name: channel
|
||||
- name: seat
|
||||
- name: frame
|
||||
- name: item
|
||||
- name: share
|
||||
|
||||
paths:
|
||||
/info:
|
||||
get:
|
||||
tags: [server]
|
||||
security: []
|
||||
summary: 服务端能力与限制
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ServerInfo' }
|
||||
|
||||
/channels:
|
||||
post:
|
||||
tags: [channel]
|
||||
security: []
|
||||
summary: 创建频道
|
||||
description: |
|
||||
服务端启用验证码时需要 `X-WD-Cap`(Cap 的 token,见 protocol §11),并需要终端头 `X-WD-Endpoint`。
|
||||
创建者的终端自动入席,占一个席位。
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/Captcha' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CreateChannelRequest' }
|
||||
responses:
|
||||
'201':
|
||||
description: 已创建
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ChannelInfo' }
|
||||
'400': { $ref: '#/components/responses/Error' }
|
||||
'409': { $ref: '#/components/responses/Error' }
|
||||
'428': { $ref: '#/components/responses/Error' }
|
||||
'429': { $ref: '#/components/responses/Error' }
|
||||
'503': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/channels/{id}:
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
get:
|
||||
tags: [channel]
|
||||
summary: 读取频道信息(不占席位)
|
||||
description: 任意角色可调用。用于加入前预览席位占用和核对模式。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ChannelInfo' }
|
||||
'401': { $ref: '#/components/responses/Error' }
|
||||
'404': { $ref: '#/components/responses/Error' }
|
||||
delete:
|
||||
tags: [channel]
|
||||
summary: 删除整个频道及全部密文(所有者)
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/RoleOwner' }
|
||||
responses:
|
||||
'204': { description: 已删除 }
|
||||
'401': { $ref: '#/components/responses/Error' }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'404': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/channels/{id}/settings:
|
||||
patch:
|
||||
tags: [channel]
|
||||
summary: 修改设置(所有者)
|
||||
description: |
|
||||
字段均可选,只修改提供的字段。`ttl_seconds` 的变更从现在起算。
|
||||
成功后向订阅者推送 `{"t":"settings"}`。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/RoleOwner' }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ChannelSettingsPatch' }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ChannelInfo' }
|
||||
'400': { $ref: '#/components/responses/Error' }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/channels/{id}/seats:
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
post:
|
||||
tags: [seat]
|
||||
summary: 加入频道(占用席位)
|
||||
description: |
|
||||
幂等:同一终端重复调用返回同一席位,不重复占用。
|
||||
席位已满返回 403 `seats_full`。
|
||||
除本接口和「读取频道信息」外,其余频道接口都要求终端已入席,否则 403 `not_joined`。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
responses:
|
||||
'200':
|
||||
description: 已入席
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/SeatJoined' }
|
||||
'401': { $ref: '#/components/responses/Error' }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'404': { $ref: '#/components/responses/Error' }
|
||||
get:
|
||||
tags: [seat]
|
||||
summary: 查看席位列表(所有者)
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/RoleOwner' }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Seat' }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/channels/{id}/seats/me:
|
||||
put:
|
||||
tags: [seat]
|
||||
summary: 终端上传自己的加密设备名
|
||||
description: 请求体是用 `K_enc` 加密后的设备名字节,≤256 B。服务端不解析。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: { type: string, format: binary, maxLength: 256 }
|
||||
responses:
|
||||
'204': { description: 已保存 }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'413': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/channels/{id}/seats/{endpointHash}:
|
||||
delete:
|
||||
tags: [seat]
|
||||
summary: 释放席位(所有者)
|
||||
description: 被释放的终端后续请求返回 `not_joined`。向订阅者推送 `{"t":"seats"}`。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- name: endpointHash
|
||||
in: path
|
||||
required: true
|
||||
description: 终端标识,即 `base64url(SHA-256(S))`
|
||||
schema: { $ref: '#/components/schemas/Base64Url' }
|
||||
- { $ref: '#/components/parameters/RoleOwner' }
|
||||
responses:
|
||||
'204': { description: 已释放 }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'404': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/channels/{id}/frames:
|
||||
post:
|
||||
tags: [frame]
|
||||
summary: 追加一帧
|
||||
description: |
|
||||
请求体是完整的帧封装(26 字节头 + 负载),格式见 `protocol.md` §4。
|
||||
幂等键为 `(channel_id, item_id, frame_index)`。内容相同则 200 返回原 seq,内容不同则 409。
|
||||
第 0 帧可带 `X-WD-Max-Downloads` 和(raw 模式)`X-WD-Label`。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/RoleWriter' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
- name: X-WD-Max-Downloads
|
||||
in: header
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0 }
|
||||
description: 条目的下载上限,0 为不限。只对 GATED 条目生效。
|
||||
- name: X-WD-Label
|
||||
in: header
|
||||
required: false
|
||||
schema: { type: string, maxLength: 255 }
|
||||
description: raw 模式的条目标签,明文。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: { type: string, format: binary }
|
||||
responses:
|
||||
'201':
|
||||
description: 新追加
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AppendResult' }
|
||||
'200':
|
||||
description: 幂等重发
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AppendResult' }
|
||||
'400': { $ref: '#/components/responses/Error' }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'409': { $ref: '#/components/responses/Error' }
|
||||
'413': { $ref: '#/components/responses/Error' }
|
||||
'429': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/channels/{id}/log:
|
||||
get:
|
||||
tags: [frame]
|
||||
summary: 按 seq 拉取日志(长轮询、续传、全量同步)
|
||||
description: |
|
||||
返回日志记录的线格式拼接(`protocol.md` §8):每条 `seq(u64) ‖ kind(u8) ‖ body`。
|
||||
`wait>0` 时无新记录会挂起,最长 25 秒,超时返回空体。
|
||||
满足 `0 < from < oldest_seq` 时返回 410 并带 `oldest_seq`,客户端必须从 `from=0` 全量同步并丢弃本地已不存在的条目(不要从 `oldest_seq` 读起)。`from=0` 永远允许。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
- name: from
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: integer, format: int64, minimum: 0, default: 0 }
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 1000, default: 200 }
|
||||
- name: wait
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0, maximum: 25, default: 0 }
|
||||
description: 长轮询等待秒数
|
||||
responses:
|
||||
'200':
|
||||
description: 记录拼接,可为空
|
||||
headers:
|
||||
X-WD-Last-Seq:
|
||||
description: 频道当前最新 seq
|
||||
schema: { type: integer, format: int64 }
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: { type: string, format: binary }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'410':
|
||||
description: 0 < from < oldest_seq,需要从 0 全量同步
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/GoneLog' }
|
||||
|
||||
/channels/{id}/ws:
|
||||
get:
|
||||
tags: [frame]
|
||||
security: []
|
||||
summary: WebSocket 订阅(降级链第一级)
|
||||
description: |
|
||||
升级为 WebSocket。浏览器不能设置头,凭据通过子协议传递:
|
||||
`Sec-WebSocket-Protocol: wd.v1, wd.role.<role>, wd.auth.<base64url(令牌)>, wd.ep.<base64url(S)>`,
|
||||
服务端回选 `wd.v1`。不要把令牌放在 URL 里。
|
||||
|
||||
**服务端 → 客户端**
|
||||
- 二进制消息:一条日志记录(`seq ‖ kind ‖ body`)。
|
||||
- 文本消息(JSON):`{"t":"hb","seq":N}` 心跳(每 20 秒);`{"t":"settings"}` 设置变更;`{"t":"seats"}` 席位变更。
|
||||
|
||||
客户端 45 秒收不到任何消息应判定死链,以指数退避(1~30 秒)加抖动重连,并带 `from=last_seq+1`。
|
||||
上传不走 WebSocket,仍用 `POST /frames`。
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- name: from
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: integer, format: int64, minimum: 0, default: 0 }
|
||||
responses:
|
||||
'101': { description: 已升级 }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'410':
|
||||
description: 0 < from < oldest_seq,需要从 0 全量同步
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/GoneLog' }
|
||||
|
||||
/channels/{id}/events:
|
||||
get:
|
||||
tags: [frame]
|
||||
summary: SSE 订阅(降级链第二级)
|
||||
description: |
|
||||
`text/event-stream`。每个事件的 `id:` 是 seq,`data:` 是 base64url 的日志记录;
|
||||
控制消息与 WebSocket 的文本消息相同,事件名为 `control`。
|
||||
`EventSource` 不能设置头,前端应使用 `fetch` 流式读取并带头部鉴权。
|
||||
续传用 `from` 或 `Last-Event-ID`。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
- name: from
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: integer, format: int64, minimum: 0, default: 0 }
|
||||
responses:
|
||||
'200':
|
||||
description: 事件流
|
||||
content:
|
||||
text/event-stream:
|
||||
schema: { type: string }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'410':
|
||||
description: 0 < from < oldest_seq,需要从 0 全量同步
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/GoneLog' }
|
||||
|
||||
/channels/{id}/items/{itemId}/frames:
|
||||
get:
|
||||
tags: [item]
|
||||
summary: 拉取条目的帧(GATED 条目的取数方式)
|
||||
description: |
|
||||
返回封装拼接,每帧自带 `payload_len`,可流式解析。条目尚未 FINAL 时响应持续输出直到 FINAL。
|
||||
`from=0` 计一次下载;`from>0` 视为续传,不计数。
|
||||
达到下载上限后返回 410 `gone`,5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/ItemId' }
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
- name: from
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: integer, format: int64, minimum: 0, default: 0 }
|
||||
description: 起始 frame_index
|
||||
responses:
|
||||
'200':
|
||||
description: 封装拼接
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: { type: string, format: binary }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'404': { $ref: '#/components/responses/Error' }
|
||||
'410': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/channels/{id}/items/{itemId}:
|
||||
delete:
|
||||
tags: [item]
|
||||
summary: 删除条目
|
||||
description: |
|
||||
按频道的 `delete_policy` 授权:`owner_only` 仅所有者;`own` 时所有者或条目作者;`anyone` 时任意已入席终端。
|
||||
成功后在日志追加 tombstone(`reason=0`)。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/ItemId' }
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
responses:
|
||||
'204': { description: 已删除 }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'404': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/shares:
|
||||
post:
|
||||
tags: [share]
|
||||
security: []
|
||||
summary: 创建便捷分享草稿
|
||||
description: 启用验证码时需携带 Cap token。创建者 endpoint 可选,创建者不计入访客数。
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/Captcha' }
|
||||
- { $ref: '#/components/parameters/ShareEndpoint' }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CreateShareRequest' }
|
||||
responses:
|
||||
'201':
|
||||
description: 已创建草稿
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ShareInfo' }
|
||||
'400': { $ref: '#/components/responses/Error' }
|
||||
'409': { $ref: '#/components/responses/Error' }
|
||||
'428': { $ref: '#/components/responses/Error' }
|
||||
'429': { $ref: '#/components/responses/Error' }
|
||||
'503': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/shares/{id}:
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ShareId' }
|
||||
get:
|
||||
tags: [share]
|
||||
summary: 读取分享信息
|
||||
description: 已封存时使用 read;草稿仅 owner 可读,read 对草稿返回 not_found。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
responses:
|
||||
'200':
|
||||
description: 分享与条目下载状态
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ShareInfo' }
|
||||
'401': { $ref: '#/components/responses/Error' }
|
||||
'404': { $ref: '#/components/responses/Error' }
|
||||
delete:
|
||||
tags: [share]
|
||||
summary: 删除草稿
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/RoleOwner' }
|
||||
responses:
|
||||
'204': { description: 草稿已删除 }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'404': { $ref: '#/components/responses/Error' }
|
||||
'409': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/shares/{id}/settings:
|
||||
patch:
|
||||
tags: [share]
|
||||
summary: 修改草稿设置
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ShareId' }
|
||||
- { $ref: '#/components/parameters/RoleOwner' }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ShareSettingsPatch' }
|
||||
responses:
|
||||
'200':
|
||||
description: 设置已更新
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ShareInfo' }
|
||||
'400': { $ref: '#/components/responses/Error' }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'409': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/shares/{id}/frames:
|
||||
post:
|
||||
tags: [share]
|
||||
summary: 向草稿追加加密帧
|
||||
description: 不支持 SNAPSHOT。第 0 帧可用 X-WD-Max-Downloads 覆盖默认下载上限。响应的 seq 是 frame_index 确认值,不是日志游标。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ShareId' }
|
||||
- { $ref: '#/components/parameters/RoleOwner' }
|
||||
- name: X-WD-Max-Downloads
|
||||
in: header
|
||||
required: false
|
||||
schema: { type: integer, format: int64, minimum: 0 }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: { type: string, format: binary }
|
||||
responses:
|
||||
'201':
|
||||
description: 新帧已追加
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AppendResult' }
|
||||
'200':
|
||||
description: 幂等重发
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AppendResult' }
|
||||
'400': { $ref: '#/components/responses/Error' }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'409': { $ref: '#/components/responses/Error' }
|
||||
'413': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/shares/{id}/items/{itemId}:
|
||||
delete:
|
||||
tags: [share]
|
||||
summary: 从草稿移除条目
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ShareId' }
|
||||
- { $ref: '#/components/parameters/ItemId' }
|
||||
- { $ref: '#/components/parameters/RoleOwner' }
|
||||
responses:
|
||||
'204': { description: 条目已删除 }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'404': { $ref: '#/components/responses/Error' }
|
||||
'409': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/shares/{id}/finish:
|
||||
post:
|
||||
tags: [share]
|
||||
summary: 封存分享
|
||||
description: 验证 manifest 是单帧非 GATED 完整条目,且草稿内所有条目完整;成功后永久禁用写权限。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ShareId' }
|
||||
- { $ref: '#/components/parameters/RoleOwner' }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/FinishShareRequest' }
|
||||
responses:
|
||||
'200':
|
||||
description: 分享已封存
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ShareInfo' }
|
||||
'400': { $ref: '#/components/responses/Error' }
|
||||
'409': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/shares/{id}/manifest:
|
||||
get:
|
||||
tags: [share]
|
||||
summary: 获取加密清单帧
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ShareId' }
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
responses:
|
||||
'200':
|
||||
description: 完整封装的二进制清单
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: { type: string, format: binary }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'404': { $ref: '#/components/responses/Error' }
|
||||
'410': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/shares/{id}/items/{itemId}/frames:
|
||||
get:
|
||||
tags: [share]
|
||||
summary: 下载封存分享中的文件帧
|
||||
description: from=0 计一次下载;from>0 为续传。首次成功读取会登记访客终端。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ShareId' }
|
||||
- { $ref: '#/components/parameters/ItemId' }
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
- name: from
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: integer, format: int64, minimum: 0, default: 0 }
|
||||
responses:
|
||||
'200':
|
||||
description: 连续封装帧拼接
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: { type: string, format: binary }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'404': { $ref: '#/components/responses/Error' }
|
||||
'410': { $ref: '#/components/responses/Error' }
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearer:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: |
|
||||
令牌的 base64url(`T_read` / `T_write` / `T_owner`),与 `X-WD-Role` 配套。
|
||||
raw 模式的读请求不需要令牌。
|
||||
|
||||
parameters:
|
||||
ChannelId:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
description: 频道 id,16 字节 base64url
|
||||
schema: { $ref: '#/components/schemas/Base64Url' }
|
||||
ShareId:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
description: 便捷分享 id,16 字节 base64url
|
||||
schema: { $ref: '#/components/schemas/Base64Url' }
|
||||
ItemId:
|
||||
name: itemId
|
||||
in: path
|
||||
required: true
|
||||
description: 条目 id,16 字节 base64url
|
||||
schema: { $ref: '#/components/schemas/Base64Url' }
|
||||
Role:
|
||||
name: X-WD-Role
|
||||
in: header
|
||||
required: true
|
||||
description: 令牌所属角色
|
||||
schema: { type: string, enum: [read, write, owner] }
|
||||
RoleWriter:
|
||||
name: X-WD-Role
|
||||
in: header
|
||||
required: true
|
||||
schema: { type: string, enum: [write, owner] }
|
||||
RoleOwner:
|
||||
name: X-WD-Role
|
||||
in: header
|
||||
required: true
|
||||
schema: { type: string, enum: [owner] }
|
||||
Endpoint:
|
||||
name: X-WD-Endpoint
|
||||
in: header
|
||||
required: true
|
||||
description: 终端密钥 `S` 的 base64url。每个频道每个终端各生成一个,仅存本地。
|
||||
schema: { $ref: '#/components/schemas/Base64Url' }
|
||||
ShareEndpoint:
|
||||
name: X-WD-Endpoint
|
||||
in: header
|
||||
required: false
|
||||
description: 创建者终端密钥 S 的 base64url,可选,不占访客名额。
|
||||
schema: { $ref: '#/components/schemas/Base64Url' }
|
||||
Captcha:
|
||||
name: X-WD-Cap
|
||||
in: header
|
||||
required: false
|
||||
description: Cap 验证码 token。服务端 `/info` 的 `captcha.enabled` 为 true 时必填;token 一次有效。
|
||||
schema: { type: string, maxLength: 2048 }
|
||||
|
||||
responses:
|
||||
Error:
|
||||
description: 错误。响应体不回显任何用户提交的内容。
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
schemas:
|
||||
Base64Url:
|
||||
type: string
|
||||
pattern: '^[A-Za-z0-9_-]+$'
|
||||
description: base64url,无填充
|
||||
|
||||
ErrorCode:
|
||||
type: string
|
||||
enum:
|
||||
- bad_request
|
||||
- unauthorized
|
||||
- forbidden
|
||||
- not_joined
|
||||
- seats_full
|
||||
- mode_mismatch
|
||||
- not_found
|
||||
- conflict
|
||||
- gone
|
||||
- too_large
|
||||
- captcha_required
|
||||
- captcha_unavailable
|
||||
- rate_limited
|
||||
- visitors_full
|
||||
- sealed
|
||||
|
||||
Error:
|
||||
type: object
|
||||
required: [error]
|
||||
properties:
|
||||
error: { $ref: '#/components/schemas/ErrorCode' }
|
||||
|
||||
GoneLog:
|
||||
type: object
|
||||
required: [error, oldest_seq]
|
||||
properties:
|
||||
error: { type: string, const: gone }
|
||||
oldest_seq: { type: integer, format: int64, description: "清理水位 + 1;0 < from < oldest_seq 的续传需要从 0 全量同步" }
|
||||
|
||||
ServerInfo:
|
||||
type: object
|
||||
required: [protocol, limits, captcha, transports]
|
||||
properties:
|
||||
protocol: { type: integer, const: 1 }
|
||||
captcha:
|
||||
type: object
|
||||
required: [provider, enabled]
|
||||
properties:
|
||||
provider: { type: string, enum: [none, cap] }
|
||||
enabled: { type: boolean }
|
||||
api_endpoint:
|
||||
type: string
|
||||
description: Cap widget 要连接的地址(data-cap-api-endpoint),仅在启用时出现
|
||||
transports:
|
||||
type: object
|
||||
properties:
|
||||
websocket: { type: boolean }
|
||||
sse: { type: boolean }
|
||||
http3: { type: boolean }
|
||||
limits:
|
||||
type: object
|
||||
properties:
|
||||
max_payload_bytes: { type: integer }
|
||||
max_frames_per_item: { type: integer }
|
||||
max_channel_bytes: { type: integer, format: int64 }
|
||||
max_seats: { type: integer }
|
||||
max_ttl_seconds: { type: integer }
|
||||
|
||||
ChannelMode:
|
||||
type: string
|
||||
enum: [enc, raw]
|
||||
|
||||
DeletePolicy:
|
||||
type: string
|
||||
enum: [owner_only, own, anyone]
|
||||
|
||||
ChannelSettings:
|
||||
type: object
|
||||
required: [seats, members_can_upload, delete_policy, ttl_seconds, max_items, file_max_downloads, ui_restrict_copy_download]
|
||||
properties:
|
||||
seats: { type: integer, minimum: 1, default: 2 }
|
||||
members_can_upload: { type: boolean, default: true }
|
||||
delete_policy: { $ref: '#/components/schemas/DeletePolicy' }
|
||||
ttl_seconds: { type: integer, minimum: 60, maximum: 604800, default: 86400 }
|
||||
max_items: { type: integer, minimum: 1, default: 1000 }
|
||||
file_max_downloads: { type: integer, minimum: 0, default: 10, description: "0 为不限,仅对 GATED 条目生效" }
|
||||
ui_restrict_copy_download: { type: boolean, default: false, description: "仅供前端展示,服务端不强制" }
|
||||
|
||||
ChannelSettingsPatch:
|
||||
type: object
|
||||
description: ChannelSettings 的全部字段均可选
|
||||
properties:
|
||||
seats: { type: integer, minimum: 1 }
|
||||
members_can_upload: { type: boolean }
|
||||
delete_policy: { $ref: '#/components/schemas/DeletePolicy' }
|
||||
ttl_seconds: { type: integer, minimum: 60, maximum: 604800 }
|
||||
max_items: { type: integer, minimum: 1 }
|
||||
file_max_downloads: { type: integer, minimum: 0 }
|
||||
ui_restrict_copy_download: { type: boolean }
|
||||
|
||||
CreateChannelRequest:
|
||||
type: object
|
||||
required: [id, mode, settings, owner_hash]
|
||||
properties:
|
||||
id: { $ref: '#/components/schemas/Base64Url' }
|
||||
mode: { $ref: '#/components/schemas/ChannelMode' }
|
||||
settings: { $ref: '#/components/schemas/ChannelSettings' }
|
||||
read_hash:
|
||||
$ref: '#/components/schemas/Base64Url'
|
||||
description: SHA-256(T_read),enc 模式必填,raw 模式省略
|
||||
write_hash:
|
||||
$ref: '#/components/schemas/Base64Url'
|
||||
description: SHA-256(T_write 或 W),仅在成员可上传时提供
|
||||
owner_hash:
|
||||
$ref: '#/components/schemas/Base64Url'
|
||||
description: SHA-256(T_owner)
|
||||
|
||||
ShareSettings:
|
||||
type: object
|
||||
required: [ttl_seconds, max_visitors, file_max_downloads]
|
||||
properties:
|
||||
ttl_seconds: { type: integer, minimum: 60, maximum: 604800, default: 86400 }
|
||||
max_visitors: { type: integer, minimum: 0, maximum: 10000, default: 0, description: 0 为不限 }
|
||||
file_max_downloads: { type: integer, minimum: 0, default: 10, description: 0 为不限 }
|
||||
|
||||
ShareSettingsPatch:
|
||||
type: object
|
||||
description: ShareSettings 的字段均可选。
|
||||
properties:
|
||||
ttl_seconds: { type: integer, minimum: 60, maximum: 604800 }
|
||||
max_visitors: { type: integer, minimum: 0, maximum: 10000 }
|
||||
file_max_downloads: { type: integer, minimum: 0 }
|
||||
|
||||
CreateShareRequest:
|
||||
type: object
|
||||
required: [id, read_hash, owner_hash]
|
||||
properties:
|
||||
id: { $ref: '#/components/schemas/Base64Url' }
|
||||
read_hash: { $ref: '#/components/schemas/Base64Url', description: SHA-256(T_read) }
|
||||
owner_hash: { $ref: '#/components/schemas/Base64Url', description: SHA-256(T_owner) }
|
||||
settings: { $ref: '#/components/schemas/ShareSettings' }
|
||||
|
||||
FinishShareRequest:
|
||||
type: object
|
||||
required: [manifest_item_id]
|
||||
properties:
|
||||
manifest_item_id: { $ref: '#/components/schemas/Base64Url' }
|
||||
|
||||
ShareItemInfo:
|
||||
type: object
|
||||
required: [item_id, bytes, frames, max_downloads, downloads_left]
|
||||
properties:
|
||||
item_id: { $ref: '#/components/schemas/Base64Url' }
|
||||
bytes: { type: integer, format: int64 }
|
||||
frames: { type: integer }
|
||||
max_downloads: { type: integer, format: int64, description: 0 为不限 }
|
||||
downloads_left: { type: [integer, 'null'], format: int64, description: "null 表示不限" }
|
||||
|
||||
ShareInfo:
|
||||
type: object
|
||||
required: [id, state, expires_at, created_at, settings, items]
|
||||
properties:
|
||||
id: { $ref: '#/components/schemas/Base64Url' }
|
||||
state: { type: string, enum: [draft, sealed] }
|
||||
expires_at: { type: integer, format: int64 }
|
||||
created_at: { type: integer, format: int64 }
|
||||
sealed_at: { type: [integer, 'null'], format: int64 }
|
||||
manifest_item_id: { type: [string, 'null'], description: 清单条目 id }
|
||||
settings: { $ref: '#/components/schemas/ShareSettings' }
|
||||
items:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/ShareItemInfo' }
|
||||
|
||||
ChannelInfo:
|
||||
type: object
|
||||
required: [id, mode, settings, seats_used, oldest_seq, last_seq, created_at, expires_at]
|
||||
properties:
|
||||
id: { $ref: '#/components/schemas/Base64Url' }
|
||||
mode: { $ref: '#/components/schemas/ChannelMode' }
|
||||
settings: { $ref: '#/components/schemas/ChannelSettings' }
|
||||
seats_used: { type: integer }
|
||||
oldest_seq: { type: integer, format: int64 }
|
||||
last_seq: { type: integer, format: int64 }
|
||||
created_at: { type: integer, format: int64 }
|
||||
expires_at: { type: integer, format: int64 }
|
||||
|
||||
SeatJoined:
|
||||
type: object
|
||||
required: [endpoint_hash, joined_at, seats_used, seats_max]
|
||||
properties:
|
||||
endpoint_hash: { $ref: '#/components/schemas/Base64Url' }
|
||||
joined_at: { type: integer, format: int64 }
|
||||
seats_used: { type: integer }
|
||||
seats_max: { type: integer }
|
||||
|
||||
Seat:
|
||||
type: object
|
||||
required: [endpoint_hash, joined_at]
|
||||
properties:
|
||||
endpoint_hash: { $ref: '#/components/schemas/Base64Url' }
|
||||
joined_at: { type: integer, format: int64 }
|
||||
label:
|
||||
type: [string, 'null']
|
||||
description: 加密的设备名(base64url),用 K_enc 解密;未设置为 null
|
||||
|
||||
AppendResult:
|
||||
type: object
|
||||
required: [seq]
|
||||
properties:
|
||||
seq: { type: integer, format: int64 }
|
||||
@@ -0,0 +1,320 @@
|
||||
# 协议 v1
|
||||
|
||||
本文是协议的权威来源。所有实现(服务端、`@webdrop/core`、将来的 CLI)必须通过 `test-vectors/` 中的向量。
|
||||
|
||||
## 1. 编码约定
|
||||
|
||||
- 多字节整数一律**大端**。
|
||||
- 二进制数据在 URL、头部、JSON 中一律 **base64url(无填充)**。
|
||||
- 随机数一律来自 CSPRNG(`crypto.getRandomValues`)。
|
||||
|
||||
| 名称 | 长度 | 说明 |
|
||||
|---|---|---|
|
||||
| `channel_id` | 16 B | 随机,客户端生成 |
|
||||
| `item_id` | 16 B | 随机,客户端生成 |
|
||||
| `K` | 32 B | 频道密钥,仅 enc 模式,只存在于 URL fragment |
|
||||
| `A` | 32 B | 管理密钥,独立随机,只存在于所有者链接 |
|
||||
| `W` | 32 B | 写密钥,仅 raw 模式且成员可上传时 |
|
||||
| `S` | 32 B | 终端密钥,每个频道每个终端各生成一个,仅存本地 |
|
||||
|
||||
## 2. 模式与链接
|
||||
|
||||
频道创建时二选一,之后不可更改。
|
||||
|
||||
| | enc(默认) | raw |
|
||||
|---|---|---|
|
||||
| 负载 | AES-256-GCM 密文 | 原样字节,服务端可见 |
|
||||
| 所有者链接 | `/c/{id}#k=<K>&a=<A>` | `/c/{id}#a=<A>[&w=<W>]` |
|
||||
| 成员链接 | `/c/{id}#k=<K>` | `/c/{id}[#w=<W>]` |
|
||||
| 读权限 | 持 `T_read` | 知道 id 即可(能力链接) |
|
||||
| 写权限 | 持 `T_write`(成员可上传时)或 `T_owner` | 持 `W` 或 `A` |
|
||||
|
||||
**降级防御**:客户端解析链接时,fragment 含 `k` 就**只**接受 `mode=enc` 的频道且只接受认证通过的帧;不含 `k` 的链接对应 raw。服务端报告的模式与链接不符时,客户端必须拒绝。
|
||||
|
||||
## 3. 密钥派生(enc)
|
||||
|
||||
HKDF-SHA256,`salt = channel_id`(16 B),输出 32 B。`info` 为 UTF-8 字符串。
|
||||
|
||||
| 输出 | IKM | info |
|
||||
|---|---|---|
|
||||
| `K_enc` | `K` | `webdrop/v1/enc` |
|
||||
| `T_read` | `K` | `webdrop/v1/token/read` |
|
||||
| `T_write` | `K` | `webdrop/v1/token/write` |
|
||||
| `T_owner` | `A` | `webdrop/v1/token/owner` |
|
||||
| `K_item` | `K_enc`,`salt = item_id` | `webdrop/v1/item` |
|
||||
| `K_label` | `K_enc`,`salt = channel_id` | `webdrop/v1/label` |
|
||||
|
||||
raw 模式下 `T_owner` 同样由 `A` 派生;`T_write = W`。
|
||||
|
||||
服务端只保存令牌的 `SHA-256` 哈希(`read_hash`、`write_hash`、`owner_hash`),验证时对请求中的令牌取哈希后做常量时间比较。
|
||||
|
||||
## 4. 帧封装(Envelope)
|
||||
|
||||
服务端可见的明文头部,固定 26 字节,后接负载。
|
||||
|
||||
```
|
||||
偏移 长度 字段
|
||||
0 1 ver = 0x01
|
||||
1 1 flags
|
||||
2 16 item_id
|
||||
18 4 frame_index 条目内的序号,从 0 开始
|
||||
22 4 payload_len 负载字节数
|
||||
26 ... payload
|
||||
```
|
||||
|
||||
`flags`:
|
||||
|
||||
| 位 | 名称 | 含义 |
|
||||
|---|---|---|
|
||||
| 0 | FINAL | 条目的最后一帧 |
|
||||
| 1 | SNAPSHOT | 该条目是频道的「最新快照」,取代之前的快照条目 |
|
||||
| 2 | GATED | 不通过实时推送内联分发,只能按条目拉取,并计下载次数 |
|
||||
| 3 | ENC | 负载为 AES-GCM 密文,必须与频道模式一致 |
|
||||
| 4-7 | 保留 | 必须为 0 |
|
||||
|
||||
服务端拒绝不符合上述规则的封装,但从不解析负载。
|
||||
|
||||
## 5. 加密(enc)
|
||||
|
||||
- 算法:AES-256-GCM,密钥为 `K_item`,标签 16 B。
|
||||
- **IV**(12 B)= `00 00 00 00 00 00 00 00 ‖ frame_index`(u32)。在每个条目独立的 `K_item` 下,`frame_index` 唯一即保证 IV 唯一。
|
||||
- **AAD** = `"WD1"`(3 B ASCII)‖ `channel_id` ‖ `envelope[0..22)`(ver、flags、item_id、frame_index)。
|
||||
- 负载 = `密文 ‖ tag`。
|
||||
|
||||
**重要规则**:同一 `(item_id, frame_index)` 只能加密一份固定明文。重试必须重发相同字节;断点续传时,明文要从文件偏移重新确定性生成,或在本地队列里持久化已加密的帧。违反规则会造成 IV 重用。
|
||||
|
||||
## 6. 明文布局与 padding
|
||||
|
||||
每帧的明文:
|
||||
|
||||
```
|
||||
data_len (u32) ‖ data ‖ 0x00 填充
|
||||
```
|
||||
|
||||
明文总长取不小于 `4 + data_len` 的最小**档位**:
|
||||
|
||||
`1 KiB, 4 KiB, 16 KiB, 64 KiB, 256 KiB, 1 MiB`
|
||||
|
||||
因此单帧明文最大 1 MiB,`data` 最大 1,048,572 B。负载长度 = 档位 + 16。接收方解密后检查 `data_len ≤ 明文长度 − 4`,并检查填充全为零。
|
||||
|
||||
大文件的中间帧都是满档(1 MiB),只有最后一帧会泄露所处档位。
|
||||
|
||||
## 7. 条目
|
||||
|
||||
条目是共享同一 `item_id` 的一组帧,`frame_index` 从 0 连续递增,最后一帧带 FINAL。
|
||||
|
||||
**第 0 帧的 `data`**:
|
||||
|
||||
```
|
||||
meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
```
|
||||
|
||||
`meta_json`(enc 模式下在密文里):
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `v` | 1 |
|
||||
| `kind` | `text` / `json` / `file` / `other` |
|
||||
| `name` | 可选,文件名 |
|
||||
| `mime` | 可选 |
|
||||
| `size` | 可选,内容总字节数(流式时可省略) |
|
||||
| `ts` | 毫秒时间戳,仅供参考,排序以 seq 为准 |
|
||||
| `dev` | 可选,设备名 |
|
||||
|
||||
第 1 帧起,`data` 全部是内容字节。小内容(文本、JSON)一帧完成,没有额外开销。
|
||||
|
||||
**raw 模式**:负载就是内容字节,没有 meta。条目名称等信息由用户在上传时放进 `X-WD-Label`(明文,≤255 B)。
|
||||
|
||||
**未完成的条目**:没有 FINAL 的条目是「进行中」,接收方可以渐进消费(流式播放、边收边写),但必须标记为不完整,直到收到已认证的 FINAL。
|
||||
|
||||
## 8. 日志、序号与条目生命周期
|
||||
|
||||
频道维护一条服务端排序的**日志**,每条记录的序号 `seq` 是 u64,频道内单调递增。
|
||||
|
||||
| kind | 值 | 内容 |
|
||||
|---|---|---|
|
||||
| frame | 1 | 一帧的完整封装(仅非 GATED 条目,它们都是单帧) |
|
||||
| tombstone | 2 | `item_id`(16 B) ‖ `reason`(u8:0 删除,1 过期,2 超限淘汰,3 下载达上限,4 被新快照取代) |
|
||||
| announce | 3 | `item_id`(16 B) ‖ `flags`(u8) ‖ `created_ms`(u64),GATED 条目只在第 0 帧到达时产生 |
|
||||
|
||||
**记录的线格式**:`seq (u64) ‖ kind (u8) ‖ body`,body 长度:frame 为 `26 + payload_len`(封装自带长度,自分隔),tombstone 固定 17 字节,announce 固定 25 字节。
|
||||
|
||||
**seq 的分配**:服务端为**每一个**被接受的帧分配 seq,包括 GATED 条目中不进入日志的后续帧,以及每条墓碑,所以日志里的 seq 可以不连续。客户端只用 seq 做排序和续传,不要假定连续。
|
||||
|
||||
**推送规则**:
|
||||
- 非 GATED 条目以 frame 记录推送给订阅者。服务端强制:**非 GATED 条目必须是单帧且带 FINAL**(`frame_index = 0`)。
|
||||
- GATED 条目只推送一条 announce,字节通过条目拉取接口取得(见 api.md),`from=0` 的拉取计入下载次数。
|
||||
- 因此客户端上传**多帧条目(文件)或带下载上限的条目时,必须设置 GATED**。
|
||||
- 同一条目的所有帧,SNAPSHOT 与 GATED 标志必须一致;FINAL 不能早于已存在的更大序号;已有 FINAL 之后不能再追加更大序号的帧。GATED 条目的帧允许乱序到达(并行上传),条目拉取只返回从起点开始连续的帧。
|
||||
|
||||
**幂等**:服务端以 `(channel_id, item_id, frame_index)` 为键。重复提交内容相同则返回原 seq,内容不同返回 409。因此客户端只需「重发直到收到确认」,断线、超时、重复提交都不会产生重复帧。
|
||||
|
||||
**续传**:客户端记住处理过的最大 seq,重连时用 `from = last_seq + 1`。
|
||||
|
||||
**墓碑与全量同步**:墓碑保留 7 天(服务端可配置)后被清理,清理水位记为 `compacted_through`,频道信息里的 `oldest_seq = compacted_through + 1`。如果客户端的 `from` 满足 `0 < from < oldest_seq`,它可能漏掉了已被清理的删除事件,服务端返回 `410` 并带 `oldest_seq`。此时客户端必须**从 `from=0` 全量同步**:重新读取日志,并丢弃本地存在、但全量日志里已不存在的条目。**不要从 `oldest_seq` 开始读**,否则会漏掉 seq 小于它的、仍然存活的条目。`from=0` 永远允许。
|
||||
|
||||
## 9. 快照
|
||||
|
||||
带 SNAPSHOT 的条目取代频道中之前的快照条目(最后写入者胜)。快照必须是**非 GATED 的单帧**(整份状态不超过一帧,约 1 MiB),所以新快照一被接受,服务端就在同一个事务里删除旧快照并产生一条 `reason = 4` 的 tombstone。一个频道在 v1 里只有一个快照槽,多文档请使用多个频道。
|
||||
|
||||
## 10. 终端与席位
|
||||
|
||||
- 终端在本地为每个频道生成随机 `S`,请求中带 `S`;服务端保存 `SHA-256(S)` 作为终端标识。
|
||||
- 终端通过显式的加入请求(`POST .../seats`)占用一个席位;创建者的终端在创建时自动入席。席位已满则拒绝新终端(`seats_full`),已入席的终端可重复加入(幂等)和重连。未入席的终端访问频道返回 `not_joined`。
|
||||
- 不同频道使用不同的 `S`,避免服务端把同一设备关联起来。
|
||||
- 终端可以加密一个设备名上传,所有者能看到成员列表和设备名,并可以释放席位。
|
||||
- **设备名格式(enc)**:`iv(12 B 随机) ‖ AES-256-GCM(K_label, utf8 名称) ‖ tag(16 B)`,AAD = `"WD1L"`(4 B ASCII)‖ `channel_id` ‖ `endpoint_hash`(32 B,即 `SHA-256(S)`)。总长不超过 256 B,所以名称最多 228 字节 UTF-8。使用派生子密钥 `K_label` 而不是直接用 `K_enc`,避免同一密钥同时用于 HKDF 和 AES-GCM;AAD 绑定终端,服务端无法把一个终端的设备名挪到另一个席位上。raw 模式下设备名是明文 UTF-8。
|
||||
|
||||
## 11. 创建与验证码
|
||||
|
||||
创建频道是匿名接口,需要防刷。验证码**完全交给 [Cap](https://github.com/tiagozip/cap)**(基于 SHA-256 工作量证明的验证码),本协议不定义、也不实现任何 PoW。
|
||||
|
||||
流程:
|
||||
1. 前端从 `GET /api/v1/info` 读到 `captcha.enabled` 和 `captcha.api_endpoint`。
|
||||
2. 如果启用,前端用 Cap 的 widget(可隐藏界面,后台求解)连接 `api_endpoint`,求解挑战并换得一个 token。
|
||||
3. 创建频道时把 token 放进请求头 `X-WD-Cap`。
|
||||
4. 服务端把 token 连同自己持有的 secret 交给 Cap 的 `POST /<site_key>/siteverify` 校验,通过才创建。token 一次有效。
|
||||
|
||||
频道 id 与 token **不绑定**(Cap 的 token 是通用的),重放由 Cap 的一次性保证。创建之后的所有操作都不需要验证码。验证码不影响加密:它既不接触密钥,也不接触内容。
|
||||
|
||||
## 12. 设置
|
||||
|
||||
频道设置对服务端可见,服务端据此执行:
|
||||
|
||||
| 设置 | 默认 | 范围 |
|
||||
|---|---|---|
|
||||
| `seats` | 2 | 1..64(服务端可配置上限) |
|
||||
| `members_can_upload` | true | |
|
||||
| `delete_policy` | `own` | `owner_only` / `own` / `anyone` |
|
||||
| `ttl_seconds` | 86400 | 最长 604800,每个条目从创建起算 |
|
||||
| `max_items` | 1000 | 超出后淘汰最旧的条目 |
|
||||
| `file_max_downloads` | 10 | 0 为不限,仅对 GATED 条目生效,可被条目单独覆盖 |
|
||||
| `ui_restrict_copy_download` | false | 仅供前端展示,服务端不强制 |
|
||||
|
||||
频道在没有存活条目且空闲超过 `ttl_seconds` 后删除。
|
||||
|
||||
## 13. 限制(服务端默认,可配置)
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 负载上限 | 1 MiB + 16 B |
|
||||
| 每条目帧数 | 65,536(约 64 GiB) |
|
||||
| 每频道字节数 | 1 GiB |
|
||||
| 每频道条目数 | `max_items` |
|
||||
|
||||
## 14. 接收方校验算法
|
||||
|
||||
对 enc 频道的每个条目:
|
||||
1. 对每个收到的帧,用 `K_item`、规定的 IV 和 AAD 解密,失败则丢弃整条目并报告篡改。
|
||||
2. 要求 `frame_index` 从 0 连续,出现缺口则条目为「不完整」。
|
||||
3. 只有收到已认证的 FINAL 帧且前面连续无缺口,才标记为「完整」。
|
||||
4. 同一 `(item_id, frame_index)` 重复出现,内容必须逐字节相同。
|
||||
5. 帧的 flags 中 ENC 必须为 1;raw 频道中必须为 0。
|
||||
|
||||
## 15. 便捷分享(Share)
|
||||
|
||||
便捷分享是和频道**并列**的另一种顶层资源:把一批文件和文本做成一份**只读清单**发给别人,**完成后封存**,之后没人能改,过期自动清除。它不是频道的变体:没有成员、没有上传、没有实时推送、没有日志和 seq。
|
||||
|
||||
| | 频道 | 便捷分享 |
|
||||
|---|---|---|
|
||||
| 路径 | `/c/{id}#k=…` | `/s/{id}#k=…` |
|
||||
| 接口 | `/api/v1/channels/*` | `/api/v1/shares/*` |
|
||||
| 模式 | enc 或 raw | **只有 enc** |
|
||||
| 谁能写 | 所有者和成员 | 只有创建者,且只在草稿阶段 |
|
||||
| 寿命 | 长期(TTL 滚动续期) | 短,封存后到期即清 |
|
||||
| 访客 | 成员,占席位 | 只读访客,按终端计数,不展示人数 |
|
||||
|
||||
分享与频道**共用同一个 16 字节 id 空间**,服务端保证全局唯一,所以同一个 id 不可能既是频道又是分享。分享 id 在频道接口上返回 `not_found`,反之亦然。
|
||||
|
||||
### 15.1 密钥
|
||||
|
||||
沿用 §3 的派生:`K → K_enc / T_read`,`A → T_owner`。分享**没有成员写入**,所以不派生也不存储 `T_write`。帧的 AAD 里的 `channel_id` 在分享中就是分享 id。
|
||||
|
||||
### 15.2 状态机
|
||||
|
||||
```
|
||||
草稿 (draft) ──[ finish ]──► 已封存 (sealed) ──[ 到期 / 下载次数用完 ]──► 清除
|
||||
│ 创建者可加、删、改设置 │ 任何人(含创建者)都不能写
|
||||
└─[ 24 小时未完成 / 创建者删除 ]──► 清除
|
||||
```
|
||||
|
||||
| 阶段 | 谁能做什么 |
|
||||
|---|---|
|
||||
| 草稿 | 只有持所有者令牌的创建者:追加帧、删除条目、改设置、删除草稿、完成分享。**读令牌看不到草稿**(返回 `not_found`),此时也没有分享链接 |
|
||||
| 已封存 | 持读令牌的任何人:读清单、读分享信息、下载。**没有任何写入口**,持所有者令牌的请求也一律拒绝 |
|
||||
| 清除 | 到有效期,整个分享被清除;文件的下载次数用完,该文件被清除 |
|
||||
|
||||
### 15.3 清单(manifest)
|
||||
|
||||
访客要看到文件名和大小,但这些在每个条目**第 0 帧的加密元数据**里,而读取 GATED 条目的第 0 帧会计一次下载,所以不能让访客逐个文件去读来拼清单。解决办法:创建者在**完成分享时**把清单本身加密成一个条目。
|
||||
|
||||
- 清单是一个**非 GATED 的单帧条目**,元数据 `kind = "manifest"`,内容是 UTF-8 JSON。
|
||||
- 服务端只知道「哪个条目是清单」(`finish` 时由创建者指定),看不到内容。
|
||||
- 清单内容(字段顺序固定,便于测试向量字节级比对):
|
||||
|
||||
```json
|
||||
{"v":1,"items":[
|
||||
{"t":"file","id":"<条目 id,base64url>","name":"a.pdf","mime":"application/pdf","size":123456,"ts":1700000000000},
|
||||
{"t":"text","name":"留言","text":"短文本直接内联在清单里","ts":1700000000000}
|
||||
]}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `t` | `file` 或 `text` |
|
||||
| `id` | 仅 `file`:文件条目的 id,访客据此下载 |
|
||||
| `name` | 文件名或文本标题,可选 |
|
||||
| `mime` | 仅 `file`,可选 |
|
||||
| `size` | 仅 `file`:内容总字节数。下载完成后必须与实际字节数一致,否则判定被截断 |
|
||||
| `text` | 仅 `text`:内容。文本**内联**在清单里,所以不占下载次数,也没有「已用完」 |
|
||||
| `ts` | 毫秒时间戳,仅供参考 |
|
||||
|
||||
清单单帧最大约 1 MiB。文本太多放不下时,把大文本当文件添加。
|
||||
|
||||
### 15.4 草稿阶段
|
||||
|
||||
- 创建者在浏览器里为每个文件或文本生成 `item_id`,加密成帧,逐帧 `POST /shares/{id}/frames`。帧格式、IV、AAD、padding、幂等重发与 §4–§7 完全相同,所以**断点续传和重试同样安全**。
|
||||
- 文件条目必须是 GATED(多帧);**不允许 SNAPSHOT 标志**;文本不单独成条目,内联进清单。
|
||||
- 文件的**下载次数上限**通过第 0 帧的 `X-WD-Max-Downloads` 设置,未设置时用分享设置里的 `file_max_downloads`。
|
||||
- 草稿在服务端有 24 小时的存活期(可配置),防止遗弃的草稿一直占着空间。草稿阶段不消耗有效期。
|
||||
|
||||
### 15.5 完成分享(封存)
|
||||
|
||||
`POST /shares/{id}/finish`,请求体 `{"manifest_item_id": "<b64url>"}`,需要所有者令牌。服务端校验:
|
||||
1. 分享处于草稿状态。
|
||||
2. 清单条目存在,是**单帧、非 GATED、已完整(带 FINAL)**。
|
||||
3. 草稿里**所有**条目都已完整(没有传了一半的)。
|
||||
|
||||
校验通过后,在**一个事务**里:
|
||||
- 状态改为已封存,记下封存时间和清单条目 id。
|
||||
- **清除所有者令牌的哈希**。此后即使有人保留了旧的所有者令牌,服务端也无法验证它,所有写操作一律拒绝。
|
||||
- 有效期从**此刻**起算:`expires_at = now + ttl_seconds`,并把所有条目的过期时间对齐到它。
|
||||
|
||||
`finish` 是**一次性、不可撤销**的。创建者的浏览器在收到成功响应后,还要自己丢弃所有者密钥 `A`(`@webdrop/core` 的 `ShareSession.markSealed()` 会做)。服务端强制和浏览器丢弃是两层保证:前者防止别人(包括恶意的、保留了令牌的人)再写,后者防止创建者自己误操作。
|
||||
|
||||
### 15.6 访客
|
||||
|
||||
- 访客打开 `/s/{id}#k=…`,**没有「加入」步骤**。`GET /shares/{id}/manifest` 或下载接口的**首次读取**会登记这个终端(终端密钥的哈希)。
|
||||
- **最多访客** `max_visitors`(0 为不限):登记满了拒绝新终端(`visitors_full`),已登记的终端可以重复访问。**创建者的终端(创建时的 `X-WD-Endpoint`)不占名额**。服务端永远不向访客展示访客人数。
|
||||
- 访客只能预览、下载、复制。没有任何编辑或删除入口。
|
||||
- `GET /shares/{id}` 返回每个文件的**剩余下载次数**(`downloads_left`,不限为 `null`),创建者封存后靠它看进度。
|
||||
|
||||
### 15.7 保证与不保证
|
||||
|
||||
保证:
|
||||
- 封存后**服务端不再接受任何写入**,包括持有旧所有者令牌的人。
|
||||
- 内容机密性、完整性与频道相同(AES-GCM、AAD 绑定分享 id、条目 id、序号、标志)。服务端没有 `K`,无法伪造清单或文件。
|
||||
- 访客用清单里的 `size` 核对下载结果;用请求的条目 id 核对帧头里的条目 id,防止服务端换成同一分享里的别的条目。
|
||||
|
||||
不保证(要如实告诉用户):
|
||||
- **发错了撤不回**,只能等过期。所以「完成分享」前必须有明确确认。
|
||||
- **持有链接就能下载**,谁拿到链接谁就是访客。
|
||||
- 服务端仍然看得到访问时间、IP 和每个文件被拉取的次数(元数据),看不到内容。
|
||||
- 服务端不受信任:它**可以**拒绝服务或提前删除内容(可用性),但**不能**让访客看到被篡改的内容而不被发现。
|
||||
- 「封存后不再接受写入」是服务端按本协议实现的行为。对一个恶意服务端,它保证不了这条;但恶意服务端没有 `K`,无法产出能通过访客校验的新帧,所以最坏情况是内容消失,而不是内容被替换。
|
||||
|
||||
## 16. 版本与测试向量
|
||||
|
||||
- 协议版本在封装的 `ver` 字段和 HKDF 的 `info`(`webdrop/v1/...`)中同时体现。
|
||||
- `test-vectors/` 提供固定输入、固定输出:密钥派生、令牌、加解密、padding、封装编解码,以及篡改、重排、截断、换频道的负例,必须全部通过。
|
||||
- 本版**预留**但不实现:口令叠加(Argon2id)、短配对码(PAKE)、多快照槽。
|
||||
@@ -0,0 +1,87 @@
|
||||
# 威胁模型
|
||||
|
||||
## 1. 信任边界
|
||||
|
||||
| 角色 | 是否受信 |
|
||||
|---|---|
|
||||
| 用户浏览器中运行的前端代码 | **受信**(信任根,见 §5 的残余风险) |
|
||||
| 后端进程、其内存、磁盘、日志、核心转储 | **不受信** |
|
||||
| 反向代理、CDN、TLS 终结点、负载均衡 | **不受信** |
|
||||
| 网络路径 | 不受信 |
|
||||
| 持有链接的成员 | 对频道内容受信,对管理权不受信 |
|
||||
|
||||
设计目标:即使整个服务端被攻击者完全控制,攻击者也读不到内容和文件名,并且无法在不被发现的情况下篡改、重排、截断或替换内容。
|
||||
|
||||
## 2. 服务端能看到什么
|
||||
|
||||
| 能看到 | 说明 |
|
||||
|---|---|
|
||||
| 频道 id、模式、设置 | 席位数、权限、TTL、下载上限等,服务端需要据此执行 |
|
||||
| 帧数量、时间、序号 | 追加顺序由服务端分配 |
|
||||
| 密文大小 | 经过档位 padding,见 protocol §6 |
|
||||
| 帧标志 | FINAL、SNAPSHOT、GATED、ENC |
|
||||
| 条目 id、帧序号 | 随机值,与内容无关 |
|
||||
| 令牌的哈希、终端的哈希 | 不是原值,但每次请求中会短暂看到令牌原值 |
|
||||
| 下载次数 | 服务端计数 |
|
||||
| 传输层信息 | IP、TLS 指纹、访问时间 |
|
||||
|
||||
| 看不到(enc 模式) | 说明 |
|
||||
|---|---|
|
||||
| 内容 | 文本、JSON、文件字节 |
|
||||
| 文件名、类型、总大小、设备名 | 都在加密的条目元数据里 |
|
||||
| 密钥 K、管理密钥 A | 只在 URL fragment 中,不会发给服务器 |
|
||||
|
||||
`raw` 模式下服务端能看到全部负载,界面必须明确提示。
|
||||
|
||||
## 3. 令牌的含义
|
||||
|
||||
令牌(`T_read` / `T_write` / `T_owner`)是授权凭证,每次请求都会经过服务端,所以恶意服务端可以截获它们。这不会导致内容泄露:
|
||||
- 令牌由 K 经 HKDF 单向派生,不同用途用不同 info 标签,令牌无法反推 K 或 K_enc。
|
||||
- 令牌只授权访问密文,而密文本来就在服务端手里。
|
||||
- 截获 `T_owner` 的恶意服务端可以改设置、删除内容,影响的是**可用性**,不是机密性或完整性。
|
||||
|
||||
## 4. 攻击与对策
|
||||
|
||||
| 攻击 | 对策 |
|
||||
|---|---|
|
||||
| 服务端篡改帧内容 | AES-GCM 认证,失败即拒绝 |
|
||||
| 重排、丢弃中间帧 | 帧序号在 AAD 中,接收方要求 0..n 连续 |
|
||||
| 截断条目 | FINAL 标志在 AAD 中,没收到已认证的 FINAL 帧就标记为不完整 |
|
||||
| 把帧搬到其他频道或条目 | AAD 绑定 channel_id 和 item_id |
|
||||
| 翻转 FINAL 或 SNAPSHOT | 标志位在 AAD 中 |
|
||||
| 模式降级(enc 换成 raw) | 链接带 `k` 就只接受 enc 且认证通过的帧,不匹配直接拒绝 |
|
||||
| 重放旧帧 | 同一条目内 (item_id, frame_index) 唯一,服务端幂等,客户端按条目去重 |
|
||||
| 服务端隐瞒或删除条目 | **无法防止**(可用性),见 §6 |
|
||||
| 链接泄露 | 链接即能力。席位上限限制加入数,所有者可释放席位和删除频道 |
|
||||
| fragment 外泄 | 取出后立即 `history.replaceState`;`Referrer-Policy: no-referrer`;不把 K 放进查询串 |
|
||||
| XSS | 严格 CSP;条目预览在沙箱中渲染;`raw` 内容不内联渲染,下载带 `attachment` 和 `nosniff` |
|
||||
| 暴力猜频道 id | id 128 位随机 |
|
||||
| 匿名滥用与 DoS | 创建时的 Cap 验证码(工作量证明由 Cap 提供)、限流、频道配额、条目数和字节数上限 |
|
||||
|
||||
## 5. 前端信任根(残余风险)
|
||||
|
||||
端到端加密的前提是浏览器执行的是正确的代码。如果同一个被攻破的后端同时下发前端,它可以换成偷 K 的页面。
|
||||
|
||||
第一版的缓解:
|
||||
- 前端静态资源托管在**独立于 API 的域名**,API 节点被攻破不影响前端。
|
||||
- 所有脚本使用 SRI,构建产物带版本哈希,发布时公布哈希。
|
||||
- 前端托管方被攻破的风险仍然存在。
|
||||
|
||||
后续:发布签名加 PWA 校验、CLI 客户端(协议不依赖浏览器,第三方可自行实现)。
|
||||
|
||||
## 6. 不做的事
|
||||
|
||||
- **不隐藏 IP 和访问时间**。需要的话用 Tor 或自己的代理。
|
||||
- **不阻止成员保留明文**。复制和下载发生在浏览器里,服务端只能限制拉取密文的次数。「界面禁止复制或下载」只是界面约束。
|
||||
- **不防服务端的可用性攻击**:隐瞒、删除或拒绝服务。
|
||||
- **不提供前向保密**:持有链接的人可以解密该频道的全部历史密文。保护手段是删除密文和 TTL。
|
||||
- **不提供不可否认性**:帧没有逐发送者签名,所有成员共享同一密钥。
|
||||
- **不防已被攻破的终端**。
|
||||
- **不做口令叠加、后量子**,留待后续版本。
|
||||
|
||||
## 7. 服务端内存与日志的要求
|
||||
|
||||
- 服务端代码路径中不出现明文和 K,也没有解密函数。
|
||||
- 令牌只做哈希比对,使用常量时间比较,不写日志。
|
||||
- 日志不记录请求头、请求体、完整 URL;频道 id 在日志中截断或哈希。
|
||||
- 错误信息不回显令牌和任何用户提交的字节。
|
||||
@@ -0,0 +1,361 @@
|
||||
# 界面设计
|
||||
|
||||
前端的视觉与交互权威文档。协议、接口以 [protocol.md](protocol.md)、[api.md](api.md) 为准,本文只定义它们在界面上如何呈现。
|
||||
|
||||
## 1. 原则
|
||||
|
||||
1. **工具感的实用极简**:界面是载体,内容是主角。信息密度适中,层级靠字号、字重、间距,不靠装饰。
|
||||
2. **诚实**:安全状态如实呈现,不夸大。服务端能看到的、不能保证的,界面必须说清楚(见 §7)。
|
||||
3. **手机与电脑同一套骨架**:同一组组件,按宽度重排,不做两套界面。
|
||||
4. **克制的质感**:只在两处用 blur,只在浮层用阴影。
|
||||
|
||||
## 2. 反 AI 味清单(禁止项)
|
||||
|
||||
| 禁止 | 原因 |
|
||||
|---|---|
|
||||
| 装饰性渐变、霓虹色、彩色大色块 | 默认 AI 模板特征。渐变只允许用来表达状态或层次,见 §4 |
|
||||
| 超过 12px 的圆角、pill 形容器 | 同上 |
|
||||
| 卡片套卡片、满屏悬浮玻璃面板 | 层级混乱,且耗性能 |
|
||||
| 眉标签(eyebrow)、装饰性副标题、营销式文案 | 工具不需要 |
|
||||
| Tailwind 默认式重阴影 | 见 §4 |
|
||||
| 默认的 Inter / Roboto,默认细线图标集原样使用 | 无个性 |
|
||||
|
||||
## 3. 色彩
|
||||
|
||||
灰白为主,亮暗两套。默认跟随系统(`prefers-color-scheme`),顶栏的外观菜单可手动指定「跟随系统 / 亮色 / 暗色」,选择只存在本机 `localStorage`,启动时在渲染前应用,避免闪烁。暗色变量在 `tokens.css` 里出现两次(跟随系统与手动指定),修改必须同步。
|
||||
|
||||
| token | 用途 |
|
||||
|---|---|
|
||||
| `--bg` / `--surface` | 页面底 / 输入、浮层底 |
|
||||
| `--line` | 1px 分隔线,替代卡片边框 |
|
||||
| `--text` / `--text-dim` | 正文 / 次要信息(时间、设备名) |
|
||||
| `--accent` | 主按钮、焦点、选中态,与正文同为墨色 |
|
||||
| `--ok` | enc 与已认证完整 |
|
||||
| `--warn` | raw 模式、不完整、下载次数将尽 |
|
||||
| `--danger` | 篡改、错误、删除 |
|
||||
| `--busy` | 进行中、重连中 |
|
||||
|
||||
**语义色只表达安全与状态,不做装饰。** 颜色字面量只允许出现在 `src/styles/tokens.css`,其余 CSS 一律 `var(--*)`,由 stylelint 强制。亮暗两套的语义色都要过对比度(正文 ≥ 4.5:1)。
|
||||
|
||||
## 4. 形状、阴影、blur
|
||||
|
||||
| 项 | 规则 |
|
||||
|---|---|
|
||||
| 圆角 | 三档:`--radius-sm` 4、`--radius-md` 8、`--radius-lg` 12。只能用 token |
|
||||
| 分隔 | 列表用 1px `--line`,不用阴影、不用卡片 |
|
||||
| 阴影 | 只有 `--shadow-pop`,只给菜单、弹窗、Toast |
|
||||
| blur | **仅顶栏与手机底部动作栏**使用 `backdrop-filter: var(--blur-bar)`,半径 10px |
|
||||
|
||||
渐变:低饱和、小面积,只表达状态或层次,定义为 `tokens.css` 里的 token(`--fade-down`、`--tile-gradient`、`--progress-gradient`、`--warn-bar`、`--*-soft` 底色):
|
||||
|
||||
| 用处 | 做法 |
|
||||
|---|---|
|
||||
| 顶栏下沿 | 向下渐隐到页面底色,代替硬分隔线 |
|
||||
| raw 警示 | 顶栏下沿换成暖色渐隐条 |
|
||||
| 传输进度 | 细条,左浅右深 |
|
||||
| 冲突、篡改、警示行 | 左侧色条 + 向右渐隐的同色底 |
|
||||
| 在线点 | 缓慢呼吸,不用发光阴影 |
|
||||
|
||||
blur 的性能约束:
|
||||
|
||||
- 全页面同时存在的 `backdrop-filter` 元素不超过 2 个。
|
||||
- 禁止用于滚动列表中的元素,以及弹窗遮罩(遮罩用纯色 `--scrim`)。
|
||||
- 必须有回退:`@supports not (backdrop-filter: blur(1px))` 与 `prefers-reduced-transparency: reduce` 时,背景退化为不透明的 `--bg`。
|
||||
- 玻璃背景下的文字对比度按「最差底色」验证,不得依赖背后内容。
|
||||
|
||||
## 5. 字体与图标
|
||||
|
||||
| 用途 | 字体 |
|
||||
|---|---|
|
||||
| 界面文字 | 思源黑体(`--font-sans`,系统无衬线兜底) |
|
||||
| 链接、哈希、密钥、文件大小 | 等宽(`--font-mono`) |
|
||||
|
||||
字号档位不超过 5 档,字重只用 400 与 600。图标自绘 SVG,统一 1.5px 描边、24 网格,放在 `ui/icons/`,不引入图标库。
|
||||
|
||||
## 6. 布局与页面
|
||||
|
||||
WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,不按「谁发的」;发送设备只作为一行小字。
|
||||
|
||||
### 6.1 手机(< 900px):最新 + 分类 + 底部动作栏
|
||||
|
||||
```
|
||||
┌────────────────────────────┐
|
||||
│▒ abc123 🔒加密 ●●○ 2/3 🔍 ◐ ⇧│ ← 模糊顶栏
|
||||
├────────────────────────────┤
|
||||
│ 最新 │ 最近一项放大,拇指区一键取走
|
||||
│ [图标] 屏幕录制.webm │
|
||||
│ iPhone · 14:20 · 1.8 MB │
|
||||
│ [ 预览 ] [ 下载 ] │ 文本则是 [ 复制 ]
|
||||
├────────────────────────────┤
|
||||
│ ▸ 便签 待办… │ 折叠,点开编辑
|
||||
├────────────────────────────┤
|
||||
│ [全部][文件][文本][图片]… │ 类型分组常驻(可横向滑动)
|
||||
│ [缩略] project-assets.zip ⬇│
|
||||
│ [缩略] 设计稿.svg v3 有更新 ⬇│
|
||||
│ [文本] 明天 10 点开会… ⧉ │
|
||||
├────────────────────────────┤
|
||||
│▒ 粘贴 文件 相册 拍照 扫码 写 │ ← 模糊底部动作栏
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
- 底部是**动作**而不是输入框:粘贴(读剪贴板,文字或图片)、文件、相册、拍照、扫码、写。点「写」才弹出输入。
|
||||
- 点列表项或「最新」卡打开底部抽屉详情(预览、操作、历史)。
|
||||
|
||||
### 6.2 电脑(≥ 900px):列表 + 内联预览 + 窄栏
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────┬───┐
|
||||
│ abc123 🔒加密 ●●○ 2/3 🔍 ◐ │ ‹ │
|
||||
├────────────────────┬──────────────────────────────┤2/3│
|
||||
│ [粘贴][文件][扫码][写]│ 屏幕录制.webm │ ⇧ │
|
||||
│ ▸ 便签 待办… │ iPhone · 14:20 · 剩 8 次下载 │ │
|
||||
│ [全部 文件 文本 …] │ [下载] [删除] │ │
|
||||
│ ☐ [缩略] zip ⬇ │ ┌────────────────────────┐ │ │
|
||||
│ ☐ [缩略] 设计稿 ⬇ │ │ 内联预览(视频/图片/目录) │ │ │
|
||||
│ ☐ [文本] 开会… ⧉ │ └────────────────────────┘ │ │
|
||||
│ 已选 2 项 [下载] │ 修改历史 v3 当前 / v2 / v1 │ │
|
||||
└────────────────────┴──────────────────────────────┴───┘
|
||||
全局:Ctrl+V 粘贴即发送;拖到窗口任意位置即上传
|
||||
```
|
||||
|
||||
- 点左侧一行,右侧内联预览,不弹窗来回开关;默认选中最新一项。
|
||||
- 多选后可批量下载。
|
||||
- 设备与分享是低频操作,默认收成右侧窄栏(在线数、分享图标),需要时展开。
|
||||
- 窗口任意位置 `Ctrl+V` 都能直接发送(在输入框内粘贴时不拦截);拖到窗口任意位置即上传。
|
||||
|
||||
### 6.3 顶栏
|
||||
|
||||
| 控件 | 作用 |
|
||||
|---|---|
|
||||
| 频道 id + 加密/明文徽标 | 当前频道与安全状态(§7.1) |
|
||||
| 在线点 + `N/M` | 设备在线情况;手机上点开设备与分享抽屉 |
|
||||
| 连接状态 | 仅在重连中或离线时显示 |
|
||||
| 搜索 | 展开关键字输入(文本内容或文件名);再次点击关闭并清除关键字。类型分组一直可见 |
|
||||
| 外观 | 跟随系统 / 亮色 / 暗色 |
|
||||
| 设备与分享(仅手机) | 打开抽屉 |
|
||||
|
||||
### 6.4 首页(初始状态)与页面
|
||||
|
||||
初衷是**快速分享文件和文本**,所以什么都没创建时,首页的主角就是「快速分享」,而不是「先建频道」。
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ WebDrop │
|
||||
│ 在自己的设备之间快速传文件和文本, │
|
||||
│ 也可以分享给别人。 │
|
||||
│ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │
|
||||
│ 快速分享 │
|
||||
│ 把文件拖到这里,或 Ctrl+V 粘贴 │
|
||||
│ 自动创建加密临时频道(24 小时) │
|
||||
│ [选择文件] [粘贴] [写文本] │ 触屏上多 [相册] [拍照]
|
||||
│ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │
|
||||
│ [ 新建频道 ] [ 加入频道 ] │
|
||||
│ 我的频道(有才显示) │
|
||||
│ abc123… 🔒加密 所有者 3 台设备 │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 操作 | 结果 |
|
||||
|---|---|
|
||||
| 快速分享(拖入、粘贴、选择、写文本、相册、拍照) | 自动创建一个**分享草稿**(加密、只读、最多 10 位访客、24 小时、每个文件下载 10 次),把内容放进去;点「完成分享」后生成链接与二维码 |
|
||||
| 新建频道 | 抽屉:模式(加密 / 明文)、设备数、保留时间、允许成员上传;创建后直接展示分享面板与二维码 |
|
||||
| 加入频道 | 扫码或粘贴链接 → 加入预览 → 加入 |
|
||||
| 我的频道 | 点一行进入;垃圾桶只是从本机移除,**不会删除频道**,需二次确认 |
|
||||
|
||||
| 路由 | 页面 |
|
||||
|---|---|
|
||||
| `/` | 首页 |
|
||||
| `/c/:id` | 已在「我的频道」里 → 频道暂存区;否则链接里带密钥 → 整页加入预览;否则「无法打开此频道」 |
|
||||
|
||||
别人发来的链接打开时,页面一加载就把 `#` 后面的密钥取走并清掉地址栏,再显示加入预览,确认后才加入。
|
||||
|
||||
频道分享面板与分享页里的二维码只给**成员链接**;所有者链接含管理权限,只提供复制,不出二维码。二维码固定深色码点、浅色底,暗色主题下也不反转,否则很多扫码器识别不了。
|
||||
|
||||
### 6.4.1 分享(快速分享的结果)
|
||||
|
||||
**分享和频道是两种东西,底层只维护一套**:分享是频道的一个预设——成员不可上传、删除策略仅所有者,并且创建者在点「完成分享」的那一刻**丢弃所有者密钥**,从此没有房主,任何人(包括创建者)都不能再追加、修改或删除,只能等 TTL 到期。
|
||||
|
||||
```
|
||||
草稿(创建者) 已封存(创建者与访客)
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ 新建分享 草稿 │ │ 我的分享 🔒已封存·只读│
|
||||
│ [粘贴][文件][写] │ │ 分享给别人 │
|
||||
│ ▢ a.zip 46MB ✕ │ │ 链接 [复制] [二维码] │
|
||||
│ 〃 开会地址… ✕ │ │ 最多 10 位 · 剩 24 小时 │
|
||||
│ 有效期 最多访客 下载次数│ │ 3 项内容 [全部下载] │
|
||||
│ [ 完成分享 ] │ │ 〃 开会地址… │
|
||||
└──────────────────────┘ │ ▢ a.zip [下载] │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
| 阶段 | 说明 |
|
||||
|---|---|
|
||||
| 草稿 | 创建者还握着所有者密钥:可以添加、移除、调整有效期、最多访客、每个文件下载次数。**此时没有分享链接**,不会被别人看到 |
|
||||
| 完成分享 | 确认框写明「此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期;发错了也撤不回」。确认后生成链接与二维码 |
|
||||
| 已封存(创建者) | 只读列表 + 「分享给别人」卡片(链接、二维码、说明) |
|
||||
| 已封存(访客) | 只读列表,只能预览、下载、复制;「全部下载」按钮 |
|
||||
|
||||
- 访客打开链接后先看加入预览,确认才占席位。访客那一端如何识别「分享」:`members_can_upload=false` 即渲染为只读收件页,不需要新协议字段。
|
||||
- 创建者的所有者链接不展示、不出二维码。
|
||||
- 首页分成「我的分享」(草稿 / 已封存 / 收到的,带剩余时间)和「我的频道」。
|
||||
|
||||
其他弹层:
|
||||
|
||||
| 名称 | 说明 |
|
||||
|---|---|
|
||||
| 条目详情 | 手机为底部抽屉,电脑为内联预览区;内容一致 |
|
||||
| 扫码加入 | 相机识别二维码(浏览器自带 `BarcodeDetector`,不支持或无权限时退回「粘贴链接」)→ 加入预览(模式、权限)→ 加入后进入该频道 |
|
||||
| 写文本 | 抽屉,`Ctrl+Enter` 发送;首页上用于快速分享 |
|
||||
| 频道设置 | 尚未实现(席位管理等) |
|
||||
|
||||
扫到的链接含密钥,只放在内存里,不写日志、不持久化。
|
||||
|
||||
> 原型说明:前端目前是验证设计的框架,频道列表只在内存里,刷新即清空;真实实现把链接存进 IndexedDB(architecture §6)。
|
||||
|
||||
### 6.5 条目
|
||||
|
||||
列表里的一行按内容类型决定画法,**不大的文本、JSON、视频直接画在行里,不用点开**:
|
||||
|
||||
| 内容 | 列表里的画法 | 快捷操作 |
|
||||
|---|---|---|
|
||||
| 文本 | 内容本身(没有标题);太长只显示前 6 行,点击进详情 | 复制 |
|
||||
| JSON(能解析成对象或数组) | 格式化后等宽显示;超过 14 行或 1200 字符截断 | 复制 |
|
||||
| 不大的视频(≤ 25 MB) | 行内播放器(`controls`、`preload="metadata"`) | 下载 |
|
||||
| 图片 | 缩略图 + 标题 + 小字 | 下载 |
|
||||
| 压缩包、其他文件、大视频 | 类型图标 + 标题 + 小字 | 下载 |
|
||||
|
||||
小字依次为:设备 · 时间 · 大小 · 剩余下载次数,后面跟状态标签:`v3`(有历史)、`有更新`、`冲突`。传输中在行内显示细进度条。点标题行或文本区域打开详情(手机底部抽屉,电脑内联预览区)。
|
||||
|
||||
**不能烧下载次数**:按协议,文件超过 1 MiB 或设了下载上限就是 GATED,每次拉取都计一次下载(api.md)。所以:
|
||||
|
||||
- 文本与 JSON 内容本来就在内存里,一律直接画。
|
||||
- 没有下载上限的视频、图片自动加载;**有下载上限的**不自动拉取:图片显示类型图标,视频显示占位「点击播放 · 会消耗 1 次下载」,点了才加载。
|
||||
- 滚动列表、刷新页面都不应该消耗下载次数。
|
||||
|
||||
- 长列表使用 `content-visibility: auto`。注意它会让后代的 `position: fixed` 失效,弹层必须用 portal 挂到 `body`。
|
||||
- **新的在前**。
|
||||
- 内联渲染一律是纯文本或原生媒体元素,不执行内容(threat-model §4)。
|
||||
|
||||
预览:
|
||||
|
||||
| 类型 | 呈现 |
|
||||
|---|---|
|
||||
| 图片、视频、音频 | 原生元素播放 blob URL,不执行内容 |
|
||||
| 文本、JSON | 纯文本等宽展示,JSON 格式化,最多读取前 200,000 字符 |
|
||||
| 压缩包 | 只列出目录树与大小,不解压、不执行 |
|
||||
| 其他 | 提示「暂不支持预览」,保留下载 |
|
||||
|
||||
### 6.6 修改、历史与便签
|
||||
|
||||
- **修改 = 追加新版本,后写者胜**(不改协议)。文本编辑后显示「已编辑」;两台设备同时改同一条时,后到者生效,先到者的内容以冲突提示展示:「某设备同时改过:… [用他的][保留我的]」。
|
||||
- **文件修改历史**:新版本是一个替代旧条目的新条目,界面把它们折叠成同一个文件。条目显示 `v3`;别的设备更新了你还没看过的文件时显示「有更新」,详情里有一行「某设备 更新了此文件 [知道了]」;详情下方列出历史版本,可单独下载旧版本。需要在加密的 `meta_json` 里加可选字段 `replaces`(旧 `item_id`),后端不解析 meta,待写入 protocol §7。
|
||||
- **便签**是频道唯一的快照槽(protocol §9),多设备实时同步,最后写入者胜,不做字符级合并。默认折叠,折叠时显示第一行;他人正在编辑时显示「某设备 正在编辑」。
|
||||
- 暂不做字符级协同编辑(需要新增协议条目类型,属于后续议题)。
|
||||
|
||||
## 7. 状态与诚实提示
|
||||
|
||||
### 7.1 频道安全徽标(顶栏)
|
||||
|
||||
| 状态 | 呈现 |
|
||||
|---|---|
|
||||
| enc | 小号锁图标 + 「加密」,`--ok`,点开说明「内容在浏览器内加密,服务端只见密文」 |
|
||||
| raw | **整条顶栏下沿加 `--warn` 色条**,文字「明文 · 服务端可见」,不可收起 |
|
||||
|
||||
### 7.2 条目状态
|
||||
|
||||
条目状态只有以下几种,由帧推导,组件按判别联合穷尽处理:
|
||||
|
||||
| 状态 | 条件 | 呈现 |
|
||||
|---|---|---|
|
||||
| `receiving` | 无 FINAL,帧连续 | `--busy` 进度,标注「不完整」 |
|
||||
| `complete` | 已认证 FINAL 且 0..n 连续 | 正常 |
|
||||
| `incomplete` | 缺口,或长时间无 FINAL | `--warn`,可重试拉取 |
|
||||
| `tampered` | 认证失败 | **整条替换为警示行**,`--danger`,说明「内容被篡改,已丢弃」,不展示任何内容 |
|
||||
| `expired` / `removed` | tombstone | 淡出后移除,可短暂保留一条「已过期 / 已删除」提示 |
|
||||
|
||||
### 7.3 连接状态(顶栏)
|
||||
|
||||
`在线` · `重连中` · `离线(N 条待发送)`。离线时仍可添加内容,发送进入队列,恢复后自动补发,无需用户操作。
|
||||
|
||||
### 7.4 必须如实表述的内容
|
||||
|
||||
| 场景 | 文案原则 |
|
||||
|---|---|
|
||||
| `ui_restrict_copy_download` | 说明这是界面限制,「成员仍可通过其他方式保存内容」,不暗示安全保证 |
|
||||
| 下载次数 | 下载前提示「此文件剩余 N 次下载」;达上限提示「已被删除」 |
|
||||
| 链接 | 分享前说明「持有链接即可访问」;成员链接与所有者链接分开展示,所有者链接标注「含管理权限」 |
|
||||
| 历史可读 | 加入说明处写明「持有链接的人可读取此频道全部历史」 |
|
||||
| 元数据 | 创建与设置页注明「不隐藏 IP 与访问时间」 |
|
||||
|
||||
### 7.5 空与错误状态
|
||||
|
||||
| 码 | 页面 |
|
||||
|---|---|
|
||||
| `seats_full` | 加入预览页:「席位已满」,告知联系所有者 |
|
||||
| `not_joined`(被释放) | 频道页整体替换为「你已被移出此频道」 |
|
||||
| `not_found` / `gone` | 「频道不存在或已过期」 |
|
||||
| `rate_limited` | Toast,带重试倒计时 |
|
||||
| 链接缺密钥 / 模式不符 | 拒绝进入,明确说明原因(降级防御,见 protocol §2) |
|
||||
|
||||
## 8. 交互
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| 发送文本 | 点「写」弹出输入,`Ctrl+Enter` 发送 |
|
||||
| 粘贴 | 窗口任意位置 `Ctrl+V` 直接发送;手机点「粘贴」读取剪贴板(无权限时退回「写」)。文件或图片直接入队,先出现「传输中」条目 |
|
||||
| 拖放 | 桌面整页接收拖放;拖入时出现覆盖提示,不改变布局 |
|
||||
| 复制 | 点击复制,按钮短暂变为「已复制」,不弹 Toast |
|
||||
| 删除 | 单击删除后条目消失并给 5 秒「撤销」(撤销只在本地延迟提交删除) |
|
||||
| 新条目到达 | 若用户已滚到底部则自动跟随,否则底部出现「N 条新内容」 |
|
||||
| 触控 | 可点击区域 ≥ 44px;底栏避开安全区(`env(safe-area-inset-*)`) |
|
||||
|
||||
## 9. 动效
|
||||
|
||||
使用 React `ViewTransition`(稳定版 React 19.3 已包含),原则是**只在有空间关系处动**:
|
||||
|
||||
| 场景 | 动画 |
|
||||
|---|---|
|
||||
| 首页 ↔ 频道 ↔ 设置(层级导航) | 方向性滑入 `nav-forward` / `nav-back` |
|
||||
| 条目新增 | CSS 淡入(`row-in`)。store 经 `useSyncExternalStore` 订阅,更新不在 transition 内,`ViewTransition` 不会触发,所以不用它 |
|
||||
| 开屏 | 单线手写 `WebDrop` 逐笔画出,约 1.2s,仅每个会话首次出现,点击或按键可跳过;加载未完成时停在末笔 |
|
||||
| 桌面左栏切换频道 | 淡入淡出,不滑动 |
|
||||
| 条目状态变化、重连、后台同步 | **无动画**(`default="none"`) |
|
||||
|
||||
规则:
|
||||
|
||||
- 一律 `default="none"`,显式开启需要的触发。
|
||||
- 时长:退出 150ms、进入 210ms、移动 400ms,取自 `--duration-*`。
|
||||
- 必须尊重 `prefers-reduced-motion`(已在 `base.css`)。
|
||||
- 顶栏、底栏用 `viewTransitionName` 隔离,并采用 backdrop-blur 的专用写法,避免闪烁。
|
||||
- 不支持的浏览器直接无动画,功能不受影响。
|
||||
|
||||
## 10. 无障碍
|
||||
|
||||
- 所有交互元素可键盘操作,焦点环可见(`--accent`)。
|
||||
- 状态不只靠颜色,配图标与文字。
|
||||
- 动态内容(新条目、连接状态)用 `aria-live="polite"`。
|
||||
- 触控目标 ≥ 44px;文本可缩放到 200% 不破版。
|
||||
|
||||
## 11. 文案
|
||||
|
||||
中文为默认语言;文案用语直白,不用感叹号与营销口吻。暂不做多语言,文案直接写在组件里。
|
||||
|
||||
## 12. 手机适配(iPhone / Android)
|
||||
|
||||
| 方面 | 做法 |
|
||||
|---|---|
|
||||
| 刘海与手势条 | `viewport-fit=cover`;顶栏用 `env(safe-area-inset-top)`,底部动作栏与抽屉用 `inset-bottom`,手机布局两侧用 `inset-left/right`(横屏) |
|
||||
| 视口高度 | `100vh` 兜底 + `100dvh`,避开地址栏伸缩 |
|
||||
| 软键盘 | 需要输入的抽屉(写文本)从**顶部**出来,iOS 不缩小布局视口,底部抽屉会被键盘遮住;Android 用 `interactive-widget=resizes-content` |
|
||||
| 输入框缩放 | 触屏上 input / textarea 字号 16px,避免 iOS 聚焦时自动放大 |
|
||||
| 点按 | `touch-action: manipulation` 去掉双击缩放延迟;去掉点按高亮;触屏上可点区域 ≥ 44px;`hover` 样式只在支持 hover 的设备生效,避免粘在上一次点击处 |
|
||||
| 返回键 | 弹层打开时压入一条历史记录,Android 返回键 / iOS 边缘滑动关闭弹层而不是离开页面(`lib/useBackToClose.ts`,这是唯一直接操作 history 的地方) |
|
||||
| 状态栏颜色 | `theme-color` 跟随当前页面底色,手动切换亮暗和系统切换时都会更新 |
|
||||
| Safari 旧版 | `backdrop-filter` 同时写 `-webkit-` 前缀 |
|
||||
| 横屏 / 矮屏 | 高度 < 500px 时隐藏「最新」卡、动作栏改为横排;宽 ≥ 900 **且**高 ≥ 500 才用电脑布局,横屏手机不会被当成电脑 |
|
||||
| 窄屏 | < 360px 时顶栏只留在线点,条目小字允许换行 |
|
||||
| 相机 | 「拍照」用 `<input capture="environment">`,iOS 与 Android 都会直接拉起相机;扫码在 Android Chrome 用 `BarcodeDetector`,iOS Safari 不支持,提示用系统相机扫码后打开链接,或粘贴链接。相机、剪贴板读取都要求 https(`localhost` 除外) |
|
||||
| 剪贴板 | iOS 读取时会弹出系统「粘贴」确认,Android 会要求权限;被拒绝时退回「写」 |
|
||||
| 图片格式 | iPhone 的 HEIC 照片在非 Safari 浏览器里无法显示,预览失败时提示直接下载 |
|
||||
| 下载 | iOS 上 `a[download]` 配合 blob 会弹出存储菜单;大文件要走 Service Worker 流式下载(架构 §6,阶段 4) |
|
||||
|
||||
Reference in new issue
Block a user