feat: integrate client and server sharing workflows

This commit is contained in:
justme committed 2026-10-02 10:49:45 +08:00
1 parent 42b93ae2d8
commit 7406e2fe37
101 files changed
+3153 -253

No files matched your search

+4 -4
View File
@@ -24,7 +24,7 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
| 读日志、拉取条目 | `read`(enc)或知道 id(raw),并占有席位 |
| 追加帧 | `write`(`members_can_upload` 为 true 时)或 `owner` |
| 删除条目 | `owner`;`delete_policy=own` 时终端哈希等于条目作者;`anyone` 时任意已入席终端 |
| 改设置、删频道、看和释放席位 | `owner` |
| 改设置、删频道、看和释放席位 | `owner`;这些管理操作不占席位 |
## 3. 接口
@@ -52,7 +52,7 @@ raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创
返回 `ChannelInfo`:设置、模式、席位占用、`oldest_seq`、`last_seq`、时间。**不占席位**,任意角色可调用,用于加入前预览和核对模式。
### `POST /api/v1/channels/{id}/seats`
加入频道,占用一个席位。幂等:同一终端重复调用返回同一席位。席位已满返回 `403 seats_full`。除本接口和上面的「读取频道信息」外,其余频道接口都要求终端已入席,否则返回 `403 not_joined`。
加入频道,占用一个席位。幂等:同一终端重复调用返回同一席位。席位已满返回 `403 seats_full`。读取频道信息和 owner 管理接口不占席位;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 `403 not_joined`。
### `PATCH /api/v1/channels/{id}/settings`
所有者修改设置。`ttl_seconds` 的修改从现在起算。
@@ -89,7 +89,7 @@ SSE 订阅(降级链的第二级)。记录以 base64url 放在 `data:` 中,`id:`
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 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载;`from>0` 只有同一终端此前对同一条目成功启动过 `from=0` 才能续传,否则返回 `410 gone`。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
### `DELETE /api/v1/channels/{id}/items/{item_id}`
按 `delete_policy` 授权后删除条目,并在日志中追加 tombstone。
@@ -166,7 +166,7 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
### `GET /api/v1/shares/{id}/items/{item_id}/frames`
与频道的 GATED 拉取一致:`from=0` 计一次下载,`from>0` 视为续传不计数;达到上限后返回 `410 gone`,5 分钟宽限期内已开始的拉取可继续续传。必须带 `X-WD-Endpoint`,同样会登记访客。
与频道的 GATED 拉取一致:`from=0` 计一次下载;`from>0` 只有同一 `X-WD-Endpoint` 此前对同一文件成功启动过 `from=0` 才能续传,否则返回 `410 gone` 且不登记访客。达到上限后返回 `410 gone`,5 分钟宽限期内已开始的拉取可继续续传。必须带 `X-WD-Endpoint`,同样会登记访客。
## 5. 错误
+45
View File
@@ -0,0 +1,45 @@
# 本地后端 Docker Compose
Compose 当前只启动 WebDrop API。前端继续使用现有的开发/独立托管方式;本地 Vite 开发服务器通过 `/api/v1` 代理访问 `127.0.0.1:5000`。API 默认只绑定 loopback,SQLite 数据保存在项目的 `./data` 目录。
## 启动
```powershell
Copy-Item .env.example .env
docker compose config
docker compose up -d --build
docker compose ps
```
API 地址为 `http://127.0.0.1:5000`。停止服务使用 `docker compose down`;数据库和服务器存储的帧会保留在 `./data`。删除该目录会永久删除这些数据。
## 前端连接
本机 `npm run dev` 使用 Vite 代理连接默认 API。独立部署前端时,在前端托管层把 `/api/v1` 代理到此 API 地址,这样无需开放 CORS。也可以为跨域托管配置 `WebDrop__CorsOrigins`,值应为明确的前端源列表。
公网部署时,在 API 前配置 HTTPS 反向代理,并让代理连接 API 的 loopback 端口。不要把未加密 HTTP 直接暴露到公网。只有当该 API 端口仅能由可信代理访问时,才将 `WEBDROP_TRUST_FORWARDED_HEADERS` 设为 `true`。
## 验证码
默认 `CAPTCHA_PROVIDER=none`。启用 Cap 时,在 `.env` 设置:
```dotenv
CAPTCHA_PROVIDER=cap
CAPTCHA_API_ENDPOINT=https://captcha.example.com/<site-key>/
CAPTCHA_SITEVERIFY_URL=https://captcha.example.com/<site-key>/siteverify
CAPTCHA_SECRET=<server-side-secret>
```
`CAPTCHA_API_ENDPOINT` 会提供给浏览器;`CAPTCHA_SITEVERIFY_URL` 和 `CAPTCHA_SECRET` 仅供 API 容器使用。不要提交 `.env`。
## Woodpecker CI
`.woodpecker/ci.yml` 在 push 和 pull request 上运行 API 契约、Core、Web、服务端检查,并解析后端 Compose 配置。每次更新都会构建前端。`.woodpecker/release-backend.yml` 仅响应 `vMAJOR.MINOR.PATCH` SemVer tag;测试通过后构建并推送同名后端镜像 tag。
后端镜像发布到 `zot.oeo.one/justme/webdrop-api`。在 Woodpecker 仓库配置 tag 事件可用的 secrets `registry_username`、`registry_password`。Docker Buildx 发布步骤以特权模式运行;Woodpecker Agent 必须把 `woodpeckerci/plugin-docker-buildx:2` 加入 `WOODPECKER_PLUGINS_PRIVILEGED`。使用已发布镜像时,在 `.env` 中设置 `WEBDROP_IMAGE_TAG`;仓库名已填入 `.env.example`。
API 契约版本以 `contracts/api-contract.json` 为准,并同步到 OpenAPI 与后端 `/api/v1/info`。加字段等向后兼容更新递增 minor,兼容性修复递增 patch,破坏性更新递增 major。前端声明支持的契约主版本;后端主版本变化时,前端 CI 契约检查和运行时连接检查都会失败,直到前端同步支持该主版本。
## 数据备份
停止服务或使用一致性快照后备份 `./data`。目录里保存 SQLite 元数据和服务端收到的帧字节;加密频道/分享的内容帧是密文。备份仍需按敏感数据保护,因为 raw 频道按设计保存明文负载。
+3 -2
View File
@@ -10,7 +10,7 @@
| 语言 | TypeScript,`strict` + `noUncheckedIndexedAccess` | |
| UI | React 19.3(稳定版,含 `ViewTransition`、`Activity`) | 不使用 canary |
| 状态 | Zustand | 按领域拆 store |
| 本地存储 | `idb`(IndexedDB) | 发送队列、「我的频道」 |
| 本地存储 | `idb`(IndexedDB) | 发送队列、「我的频道」、草稿预览所需的本机文件 |
| 样式 | 原生 CSS + CSS 变量 + CSS Modules | 无 Tailwind、无 CSS-in-JS |
| 路由 | 自写极简导航(`app/navigation.ts`) | 只有两三个路由,且需统一挂 view transition |
| 测试 | Vitest(单元)、Playwright(端到端) | |
@@ -90,6 +90,7 @@ type ItemState =
| K 导入为不可导出的 `CryptoKey` | 内存中不保留原始字节 |
| 降级防御 | 链接含 `k` 就只接受 enc 且认证通过的帧 |
| 不把密钥、令牌、明文写入 `console`、错误上报、URL、`localStorage` | 「我的频道」只存 IndexedDB |
| 便捷分享草稿预览 | 原始文件只缓存在创建者本机 IndexedDB,不会随文件帧上传;移除条目或封存时清理,用于刷新后继续预览草稿 |
| 条目预览在沙箱 iframe 渲染;raw 内容只当纯文本显示或下载 | 无内联渲染,不使用 `dangerouslySetInnerHTML` |
| 严格 CSP,所有脚本样式带 SRI,不使用内联脚本 | 与 architecture §5 一致 |
| 不引入任何第三方脚本、统计、字体 CDN | 字体自托管 |
@@ -168,6 +169,6 @@ type ItemState =
| 文件下载次数 | 每个条目的 `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` |
| 创建频道、便捷分享的验证码 | 读 `GET /info` 的 `captcha`;需要时按需加载打包在前端的官方 `cap-widget`,求解后只把一次性 token 放进创建请求的 `X-WD-Cap`。不从 CDN 加载脚本 |
| 校验失败 | reader 的 `rejected` 事件不能忽略,至少在界面提示「内容校验失败」 |
| Worker | `ChannelSession` 不能跨线程,在 Worker 里用链接和 `endpointSecret` 重建 |
+11 -8
View File
@@ -75,6 +75,7 @@ paths:
delete:
tags: [channel]
summary: 删除整个频道及全部密文(所有者)
description: 有效的 owner 凭据即可调用,不要求 owner 终端占用席位。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/RoleOwner' }
@@ -90,7 +91,7 @@ paths:
summary: 修改设置(所有者)
description: |
字段均可选,只修改提供的字段。`ttl_seconds` 的变更从现在起算。
成功后向订阅者推送 `{"t":"settings"}`。
成功后向订阅者推送 `{"t":"settings"}`。有效的 owner 凭据即可调用,不要求 owner 终端占用席位。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
@@ -118,7 +119,7 @@ paths:
description: |
幂等:同一终端重复调用返回同一席位,不重复占用。
席位已满返回 403 `seats_full`。
除本接口和「读取频道信息」外,其余频道接口都要求终端已入席,否则 403 `not_joined`。
读取频道信息和 owner 管理接口不占席位;频道内容、席位信息/设备名等其余操作要求终端已入席,否则 403 `not_joined`。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/Role' }
@@ -135,6 +136,7 @@ paths:
get:
tags: [seat]
summary: 查看席位列表(所有者)
description: 有效的 owner 凭据即可调用,不要求 owner 终端占用席位。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/RoleOwner' }
@@ -172,7 +174,7 @@ paths:
delete:
tags: [seat]
summary: 释放席位(所有者)
description: 被释放的终端后续请求返回 `not_joined`。向订阅者推送 `{"t":"seats"}`。
description: 有效的 owner 凭据即可调用,不要求 owner 终端占用席位。被释放终端后续请求返回 `not_joined`;向订阅者推送 `{"t":"seats"}`。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
@@ -343,7 +345,7 @@ paths:
summary: 拉取条目的帧(GATED 条目的取数方式)
description: |
返回封装拼接,每帧自带 `payload_len`,可流式解析。条目尚未 FINAL 时响应持续输出直到 FINAL。
`from=0` 计一次下载;`from>0` 视为续传,不计数。
`from=0` 计一次下载;`from>0` 只有同一终端此前对同一条目成功启动过 `from=0` 才能续传,否则返回 410 `gone`。
达到下载上限后返回 410 `gone`,5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
security: [{ bearer: [] }]
parameters:
@@ -558,7 +560,7 @@ paths:
get:
tags: [share]
summary: 下载封存分享中的文件帧
description: from=0 计一次下载;from>0 为续传。首次成功读取会登记访客终端。
description: from=0 计一次下载;from>0 只有同一 X-WD-Endpoint 此前对同一文件成功启动过 from=0 才能续传,否则返回 410 gone 且不登记访客。首次成功读取会登记访客终端。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ShareId' }
@@ -689,9 +691,10 @@ components:
ServerInfo:
type: object
required: [protocol, limits, captcha, transports]
properties:
protocol: { type: integer, const: 1 }
required: [protocol, contract_version, limits, captcha, transports]
properties:
protocol: { type: integer, const: 1 }
contract_version: { type: string, example: 1.0.0, description: API 契约的 SemVer。破坏性变更递增主版本。 }
captcha:
type: object
required: [provider, enabled]
+2
View File
@@ -144,6 +144,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
**推送规则**:
- 非 GATED 条目以 frame 记录推送给订阅者。服务端强制:**非 GATED 条目必须是单帧且带 FINAL**(`frame_index = 0`)。
- GATED 条目只推送一条 announce,字节通过条目拉取接口取得(见 api.md),`from=0` 的拉取计入下载次数。
- `from>0` 只有在同一终端此前已对同一条目成功发起 `from=0` 后才是有效续传;服务端按资源、条目和终端绑定这份续传许可,任意跳到中间帧不能绕过下载次数。
- 因此客户端上传**多帧条目(文件)或带下载上限的条目时,必须设置 GATED**。
- 同一条目的所有帧,SNAPSHOT 与 GATED 标志必须一致;FINAL 不能早于已存在的更大序号;已有 FINAL 之后不能再追加更大序号的帧。GATED 条目的帧允许乱序到达(并行上传),条目拉取只返回从起点开始连续的帧。
@@ -276,6 +277,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
- 创建者在浏览器里为每个文件或文本生成 `item_id`,加密成帧,逐帧 `POST /shares/{id}/frames`。帧格式、IV、AAD、padding、幂等重发与 §4–§7 完全相同,所以**断点续传和重试同样安全**。
- 文件条目必须是 GATED(多帧);**不允许 SNAPSHOT 标志**;文本不单独成条目,内联进清单。
- 文件的**下载次数上限**通过第 0 帧的 `X-WD-Max-Downloads` 设置,未设置时用分享设置里的 `file_max_downloads`。
- `from>0` 续传必须来自同一 `X-WD-Endpoint` 对同一文件此前启动的 `from=0` 下载;否则服务端返回 `410 gone` 且不登记访客。
- 草稿在服务端有 24 小时的存活期(可配置),防止遗弃的草稿一直占着空间。草稿阶段不消耗有效期。
### 15.5 完成分享(封存)
+27 -22
View File
@@ -136,28 +136,29 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
### 6.4 首页(初始状态)与页面
初衷是**快速分享文件和文本**,所以什么都没创建时,首页的主角就是「快速分享」,而不是「先建频道」。
首页把「临时分享」与「设备频道」作为两个并列入口:前者用于发一段临时文本或文件,后者用于多设备长期同步。
```
┌──────────────────────────────────┐
│ WebDrop │
│ 在自己的设备之间快速传文件和文本, │
│ 也可以分享给别人。 │
│ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │
│ 快速分享 │
│ 把文件拖到这里,或 Ctrl+V 粘贴 │
│ 自动创建加密临时频道(24 小时) │
│ [选择文件] [粘贴] [写文本] │ 触屏上多 [相册] [拍照]
│ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │
│ [ 新建频道 ] [ 加入频道 ] │
│ 在自己的设备之间传文件和文本,也可分享给别人。│
│ ┌ 临时分享 ───────────────────┐ │
│ │ 输入或粘贴临时文本… │ │
│ │ 拖入文件 / 文件名列表 │ │
│ │ [选择文件] [粘贴] [创建临时分享]│ │
│ └────────────────────────────┘ │
│ 我的频道(有才显示) │
│ abc123… 🔒加密 所有者 3 台设备 │
│ ┌ 设备频道 ───────────────────┐ │
│ │ 多台设备之间长期同步 │ │
│ │ [ 新建频道 ] [ 加入频道 ] │ │
│ └────────────────────────────┘ │
└──────────────────────────────────┘
```
| 操作 | 结果 |
|---|---|
| 快速分享(拖入、粘贴、选择、写文本、相册、拍照) | 自动创建一个**分享草稿**(加密、只读、最多 10 位访客、24 小时、每个文件下载 10 次),把内容放进去;点「完成分享」后生成链接与二维码 |
| 快速分享(输入文本、拖入、粘贴、选择、相册、拍照) | 先在首页编辑临时文本并收集文件;点「创建临时分享」后建立加密草稿并带入内容;点「完成分享」后生成链接与二维码 |
| 新建频道 | 抽屉:模式(加密 / 明文)、设备数、保留时间、允许成员上传;创建后直接展示分享面板与二维码 |
| 加入频道 | 扫码或粘贴链接 → 加入预览 → 加入 |
| 我的频道 | 点一行进入;垃圾桶只是从本机移除,**不会删除频道**,需二次确认 |
@@ -177,21 +178,25 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
```
草稿(创建者) 已封存(创建者与访客)
┌──────────────────────┐ ┌──────────────────────┐
│ 新建分享 草稿 │ │ 我的分享 🔒已封存·只读│
│ [粘贴][文件][写] │ │ 分享给别人 │
│ ▢ a.zip 46MB ✕ │ │ 链接 [复制] [二维码] │
│ 〃 开会地址… ✕ │ │ 最多 10 位 · 剩 24 小时 │
│ 有效期 最多访客 下载次数│ │ 3 项内容 [全部下载] │
│ [ 完成分享 ] │ │ 〃 开会地址… │
└──────────────────────┘ │ ▢ a.zip [下载] │
└──────────────────────┘
┌────────────────────────────────┐ ┌──────────────────────┐
│ 新建分享 · 草稿 [粘贴][文件][写]│ │ 我的分享 · 已封存只读 │
├───────────────┬────────────────┤ │ 分享链接 [复制][二维码]│
│ 内容清单 │ 选中的内容 │ │ 3 项内容 [全部下载] │
│ ▢ a.zip 46MB ✕│ [文件预览] │ │ 〃 开会地址… │
│ 〃 文本 120B ✕ │ [替换文件] │ │ ▢ a.zip [下载] │
│ │ │ └──────────────────────┘
│ │ 文本可直接编辑 │
├───────────────┴────────────────┤
│ 有效期 · 最多访客 · 下载次数 │
│ □ 我已了解,完成后无法再修改 │
│ [ 完成分享 ] │
└────────────────────────────────┘
```
| 阶段 | 说明 |
|---|---|
| 草稿 | 创建者还握着所有者密钥:可以添加、移除、调整有效期、最多访客、每个文件下载次数。**此时没有分享链接**,不会被别人看到 |
| 完成分享 | 确认框写明「此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期;发错了也撤不回」。确认后生成链接与二维码 |
| 草稿 | 左侧列出内容,选择后在右侧预览;创建者还握着所有者密钥,可直接修改文本、替换或移除文件,并调整有效期、最多访客、每个文件下载次数。**此时没有分享链接**,不会被别人看到 |
| 完成分享 | 页面内勾选「完成后会生成分享链接和二维码。此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期」后,按钮才可用;点击即生成链接与二维码 |
| 已封存(创建者) | 只读列表 + 「分享给别人」卡片(链接、二维码、说明) |
| 已封存(访客) | 只读列表,只能预览、下载、复制;「全部下载」按钮 |