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

+16
View File
@@ -0,0 +1,16 @@
.git
.gitignore
.dockerignore
.env
.env.*
!.env.example
**/node_modules
**/dist
**/bin
**/obj
**/*.tsbuildinfo
server/out
server/.idea
.playwright-mcp
.vscode
.idea
+18
View File
@@ -0,0 +1,18 @@
# API only. Loopback binding lets a local frontend or host reverse proxy reach it.
API_BIND_ADDRESS=127.0.0.1
API_PORT=5000
WEBDROP_IMAGE_REPO=zot.oeo.one/justme/webdrop-api
WEBDROP_IMAGE_TAG=local
WEBDROP_REQUESTS_PER_MINUTE=600
WEBDROP_CREATES_PER_MINUTE=30
WEBDROP_SWEEP_INTERVAL_SECONDS=30
# Enable only when requests can arrive solely through a trusted reverse proxy.
WEBDROP_TRUST_FORWARDED_HEADERS=false
# Keep "none" for local development. Configure all Cap values to enable it.
CAPTCHA_PROVIDER=none
CAPTCHA_API_ENDPOINT=
CAPTCHA_SITEVERIFY_URL=
CAPTCHA_SECRET=
CAPTCHA_TIMEOUT_SECONDS=5
+3
View File
@@ -16,6 +16,9 @@ server/.idea/
**/*.db
**/*.db-shm
**/*.db-wal
.env
.env.*
!.env.example
# Editor and OS files
.vscode/
+38
View File
@@ -0,0 +1,38 @@
when:
- event: [push, pull_request]
steps:
api-contract:
image: node:24.21.0-alpine3.23
commands:
- node scripts/check-api-contract.mjs
core-and-web:
image: node:24.21.0-alpine3.23
directory: packages/core
commands:
- npm ci
- npm run typecheck
- npm test
- npm run vectors:check
- npm run build
web:
image: node:24.21.0-alpine3.23
directory: web
commands:
- npm ci
- npm run typecheck
- npm run lint:css
- npm test
- npm run build
server:
image: mcr.microsoft.com/dotnet/sdk:10.0
commands:
- dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal
compose-config:
image: docker:29.8.1-cli-alpine3.24
commands:
- docker compose --env-file .env.example config --quiet
+29
View File
@@ -0,0 +1,29 @@
when:
- event: tag
ref: refs/tags/v*
steps:
release-checks:
image: mcr.microsoft.com/dotnet/sdk:10.0
commands:
- dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal
validate-release:
image: node:24.21.0-alpine3.23
commands:
- node scripts/check-api-contract.mjs "$CI_COMMIT_TAG"
publish-backend:
image: woodpeckerci/plugin-docker-buildx:2
privileged: true
settings:
context: .
dockerfile: server/Dockerfile
platforms: linux/amd64
repo: zot.oeo.one/justme/webdrop-api
registry: zot.oeo.one
tags: ${CI_COMMIT_TAG}
username:
from_secret: registry_username
password:
from_secret: registry_password
+1
View File
@@ -27,6 +27,7 @@
| [web/AGENTS.md](web/AGENTS.md) | 前端契约:权威顺序、范围、必须遵守与禁止项 |
| [packages/core/README.md](packages/core/README.md) | `@webdrop/core` 的接口、前端要做和不用做的事、必须注意的几点 |
| [server/README.md](server/README.md) | 服务端的结构、配置、AOT 发布、验证码(Cap)接入 |
| [docs/deployment.md](docs/deployment.md) | Docker Compose 自托管部署与 Woodpecker CI |
| [test-vectors/README.md](test-vectors/README.md) | 协议测试向量的生成与使用 |
## 核心概念
+32
View File
@@ -0,0 +1,32 @@
services:
api:
build:
context: .
dockerfile: server/Dockerfile
image: ${WEBDROP_IMAGE_REPO:-webdrop-api}:${WEBDROP_IMAGE_TAG:-local}
platform: linux/amd64
restart: unless-stopped
init: true
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
ASPNETCORE_URLS: http://+:5000
WebDrop__DataDir: /data
WebDrop__RequestsPerMinute: ${WEBDROP_REQUESTS_PER_MINUTE:-600}
WebDrop__ChannelCreatesPerMinute: ${WEBDROP_CREATES_PER_MINUTE:-30}
WebDrop__SweepIntervalSeconds: ${WEBDROP_SWEEP_INTERVAL_SECONDS:-30}
WebDrop__TrustForwardedHeaders: ${WEBDROP_TRUST_FORWARDED_HEADERS:-false}
WebDrop__Captcha__Provider: ${CAPTCHA_PROVIDER:-none}
WebDrop__Captcha__ApiEndpoint: ${CAPTCHA_API_ENDPOINT:-}
WebDrop__Captcha__SiteverifyUrl: ${CAPTCHA_SITEVERIFY_URL:-}
WebDrop__Captcha__Secret: ${CAPTCHA_SECRET:-}
WebDrop__Captcha__TimeoutSeconds: ${CAPTCHA_TIMEOUT_SECONDS:-5}
ports:
- "${API_BIND_ADDRESS:-127.0.0.1}:${API_PORT:-5000}:5000"
volumes:
- ./data:/data
+3
View File
@@ -0,0 +1,3 @@
{
"version": "1.0.0"
}
+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 [下载] │
│ │ │ └──────────────────────┘
│ │ 文本可直接编辑 │
├───────────────┴────────────────┤
│ 有效期 · 最多访客 · 下载次数 │
│ □ 我已了解,完成后无法再修改 │
│ [ 完成分享 ] │
└────────────────────────────────┘
```
| 阶段 | 说明 |
|---|---|
| 草稿 | 创建者还握着所有者密钥:可以添加、移除、调整有效期、最多访客、每个文件下载次数。**此时没有分享链接**,不会被别人看到 |
| 完成分享 | 确认框写明「此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期;发错了也撤不回」。确认后生成链接与二维码 |
| 草稿 | 左侧列出内容,选择后在右侧预览;创建者还握着所有者密钥,可直接修改文本、替换或移除文件,并调整有效期、最多访客、每个文件下载次数。**此时没有分享链接**,不会被别人看到 |
| 完成分享 | 页面内勾选「完成后会生成分享链接和二维码。此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期」后,按钮才可用;点击即生成链接与二维码 |
| 已封存(创建者) | 只读列表 + 「分享给别人」卡片(链接、二维码、说明) |
| 已封存(访客) | 只读列表,只能预览、下载、复制;「全部下载」按钮 |
+1 -1
View File
@@ -46,4 +46,4 @@ export const LABEL_IV_SIZE = 12;
export const POW_NONCE_SIZE = 8;
export type ChannelMode = 'enc' | 'raw';
export type ItemKind = 'text' | 'json' | 'file' | 'other';
export type ItemKind = 'text' | 'json' | 'file' | 'manifest' | 'other';
+1 -1
View File
@@ -16,7 +16,7 @@ export interface ItemMeta {
dev?: string;
}
const KINDS: readonly ItemKind[] = ['text', 'json', 'file', 'other'];
const KINDS: readonly ItemKind[] = ['text', 'json', 'file', 'manifest', 'other'];
/** u16 meta_len ‖ meta_json。字段顺序固定,undefined 的字段省略,保证字节级确定。 */
export function encodeMeta(meta: ItemMeta): Bytes {
+39
View File
@@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs';
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
const semver = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
const contract = JSON.parse(read('contracts/api-contract.json'));
const fail = (message) => {
console.error(`API contract check failed: ${message}`);
process.exit(1);
};
if (typeof contract.version !== 'string' || !semver.test(contract.version)) {
fail('contracts/api-contract.json must contain a SemVer version.');
}
const apiMajor = Number(contract.version.split('.')[0]);
const openapi = read('docs/openapi.yaml');
const openapiVersion = /^info:\s*\r?\n(?:.*\r?\n)*?\s+version:\s*([^\s#]+)/m.exec(openapi)?.[1];
if (openapiVersion !== contract.version) fail(`OpenAPI version ${openapiVersion ?? '(missing)'} differs from ${contract.version}.`);
const backend = read('server/src/WebDrop.Api/ApiContract.cs');
const backendVersion = /const string Version = "([^"]+)"/.exec(backend)?.[1];
if (backendVersion !== contract.version) fail(`backend version ${backendVersion ?? '(missing)'} differs from ${contract.version}.`);
const frontend = read('web/src/core-bridge/contract.ts');
const supportedMajor = Number(/SUPPORTED_API_CONTRACT_MAJOR\s*=\s*(\d+)/.exec(frontend)?.[1]);
if (supportedMajor !== apiMajor) {
fail(`backend contract is ${contract.version}; frontend still supports API major ${supportedMajor || '(missing)'}. Update web/src/core-bridge/contract.ts.`);
}
const tag = process.argv[2];
if (tag !== undefined) {
if (!tag.startsWith('v')) fail(`release tag ${tag} must start with v.`);
const releaseVersion = tag.slice(1);
if (!semver.test(releaseVersion) || releaseVersion.includes('+')) {
fail(`release tag ${tag} must be a SemVer version usable as an OCI image tag (expected vMAJOR.MINOR.PATCH[-prerelease]).`);
}
}
console.log(`API contract ${contract.version}: backend, OpenAPI, and frontend major ${supportedMajor} agree.`);
+28
View File
@@ -0,0 +1,28 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
RUN apt-get update \
&& apt-get install -y --no-install-recommends clang zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
COPY server/ ./server/
RUN dotnet publish server/src/WebDrop.Api/WebDrop.Api.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained true \
--output /out
RUN find /root/.nuget/packages/sqlitepclraw.lib.e_sqlite3 -path '*/runtimes/linux-x64/native/libe_sqlite3.so' \
-exec cp {} /out/libe_sqlite3.so \; \
&& test -f /out/libe_sqlite3.so
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0 AS runtime
WORKDIR /app
COPY --from=build /out/webdrop /app/webdrop
COPY --from=build /out/libe_sqlite3.so /app/libe_sqlite3.so
RUN mkdir -p /data \
&& chown 10001:10001 /data \
&& chmod 0750 /data
ENV ASPNETCORE_URLS=http://+:5000 \
WebDrop__DataDir=/data \
DOTNET_EnableDiagnostics=0
USER 10001:10001
EXPOSE 5000
ENTRYPOINT ["/app/webdrop"]
+7
View File
@@ -0,0 +1,7 @@
namespace WebDrop.Api;
public static class ApiContract
{
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
public const string Version = "1.0.0";
}
+1
View File
@@ -17,6 +17,7 @@ public sealed class GoneLogBody
public sealed class ServerInfoBody
{
[JsonPropertyName("protocol")] public int Protocol { get; init; } = 1;
[JsonPropertyName("contract_version")] public string ContractVersion { get; init; } = ApiContract.Version;
[JsonPropertyName("captcha")] public required CaptchaBody Captcha { get; init; }
[JsonPropertyName("transports")] public required TransportsBody Transports { get; init; }
[JsonPropertyName("limits")] public required LimitsBody Limits { get; init; }
+2
View File
@@ -82,6 +82,8 @@ public interface IResourceTx
void InsertShareItem(ShareItem item);
void UpdateShareItem(ShareItem item);
void DeleteShareItemRows(Id16 shareId, Id16 itemId);
bool HasDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash);
void AddDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash);
ShareFrame? GetShareFrame(Id16 shareId, Id16 itemId, uint index);
void InsertShareFrame(ShareFrame frame);
IReadOnlyList<ShareFrame> ListShareFrames(Id16 shareId, Id16 itemId, uint fromIndex, int limit);
+17 -4
View File
@@ -324,6 +324,9 @@ public sealed class ChannelService(
if (!store.Read(tx => tx.BlobKeyExists(blobKey))) blobs.Delete(blobKey);
}
private static bool IsItemReadable(Item item, long now) =>
item.ExpiresAt > now && (item.CloseDeadline is null || item.CloseDeadline > now);
// ───────────────────────── 读取 ─────────────────────────
public async Task<LogPage> ReadLogAsync(Id16 id, Credentials creds, long from, int limit, CancellationToken ct = default)
@@ -338,16 +341,19 @@ public sealed class ChannelService(
if (from > 0 && from < channel.CompactedThrough + 1)
throw new WebDropException(ErrorCode.Gone, channel.CompactedThrough + 1);
long now = clock.NowMs;
var list = new List<(long Seq, byte[]? Bytes, StoredFrame? Frame)>();
foreach (var e in tx.ReadLog(id, from, limit))
{
switch (e.Kind)
{
case LogKind.Frame:
if (tx.GetFrame(id, e.ItemId, e.FrameIndex) is { } f) list.Add((e.Seq, null, f));
if (tx.GetItem(id, e.ItemId) is { } frameItem && IsItemReadable(frameItem, now)
&& tx.GetFrame(id, e.ItemId, e.FrameIndex) is { } f)
list.Add((e.Seq, null, f));
break;
case LogKind.Announce:
if (tx.GetItem(id, e.ItemId) is { } it)
if (tx.GetItem(id, e.ItemId) is { } it && IsItemReadable(it, now))
list.Add((e.Seq, LogRecord.EncodeAnnounce(e.Seq, e.ItemId, it.Flags, it.CreatedAt), null));
break;
case LogKind.Tombstone:
@@ -378,7 +384,10 @@ public sealed class ChannelService(
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
long now = clock.NowMs;
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now > deadline))
if (item.ExpiresAt <= now) throw new WebDropException(ErrorCode.Gone);
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline))
throw new WebDropException(ErrorCode.Gone);
if (item.IsGated && fromIndex > 0 && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash))
throw new WebDropException(ErrorCode.Gone);
if (fromIndex == 0 && item.IsGated)
@@ -391,6 +400,7 @@ public sealed class ChannelService(
? now + limits.DownloadGraceSeconds * 1000L
: item.CloseDeadline;
tx.UpdateItem(item with { Downloads = downloads, CloseDeadline = closeAt });
tx.AddDownloadGrant(id, itemId, principal.EndpointHash);
}
return 0;
});
@@ -406,7 +416,10 @@ public sealed class ChannelService(
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required);
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (item.CloseDeadline is { } d && clock.NowMs > d) throw new WebDropException(ErrorCode.Gone);
if (item.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.Gone);
if (item.IsGated && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash))
throw new WebDropException(ErrorCode.Gone);
if (item.CloseDeadline is { } d && clock.NowMs >= d) throw new WebDropException(ErrorCode.Gone);
var taken = new List<StoredFrame>();
uint expected = fromIndex;
+10 -2
View File
@@ -210,11 +210,14 @@ public sealed class ShareService(
store.Write(tx =>
{
var share = SealedForRead(tx.GetShare(id), creds);
RegisterVisitor(tx, share, creds.EndpointHash);
var item = tx.GetShareItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!item.IsGated) throw new WebDropException(ErrorCode.BadRequest);
if (fromIndex > 0 && !tx.HasDownloadGrant(id, itemId, creds.EndpointHash))
throw new WebDropException(ErrorCode.Gone);
long now = clock.NowMs;
if (item.CloseDeadline is { } d && (fromIndex == 0 || now > d)) throw new WebDropException(ErrorCode.Gone);
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline))
throw new WebDropException(ErrorCode.Gone);
RegisterVisitor(tx, share, creds.EndpointHash);
if (fromIndex == 0)
{
if (item.MaxDownloads > 0 && item.Downloads >= item.MaxDownloads) throw new WebDropException(ErrorCode.Gone);
@@ -223,6 +226,7 @@ public sealed class ShareService(
? now + limits.DownloadGraceSeconds * 1000L
: item.CloseDeadline;
tx.UpdateShareItem(item with { Downloads = count, CloseDeadline = closeAt });
tx.AddDownloadGrant(id, itemId, creds.EndpointHash);
}
return 0;
});
@@ -234,6 +238,10 @@ public sealed class ShareService(
{
_ = SealedForRead(tx.GetShare(id), creds);
var item = tx.GetShareItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (item.IsGated && !tx.HasDownloadGrant(id, itemId, creds.EndpointHash))
throw new WebDropException(ErrorCode.Gone);
if (item.CloseDeadline is { } deadline && clock.NowMs >= deadline)
throw new WebDropException(ErrorCode.Gone);
var list = tx.ListShareFrames(id, itemId, fromIndex, 32);
uint expected = fromIndex;
foreach (var frame in list)
@@ -10,7 +10,7 @@ namespace WebDrop.Storage;
/// </summary>
public sealed class SqliteChannelStore : IResourceStore, IDisposable
{
private const int SchemaVersion = 2;
private const int SchemaVersion = 3;
private readonly string _connectionString;
private readonly SqliteConnection _writeConn;
@@ -93,6 +93,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
using var tx = _writeConn.BeginTransaction();
if (version == 0) Exec(_writeConn, SchemaV1);
if (version < 2) Exec(_writeConn, SchemaV2);
if (version < 3) Exec(_writeConn, SchemaV3);
Exec(_writeConn, $"PRAGMA user_version={SchemaVersion};");
tx.Commit();
}
@@ -222,6 +223,16 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
CREATE INDEX ix_share_frames_blob ON share_frames(blob_key) WHERE blob_key IS NOT NULL;
""";
private const string SchemaV3 = """
CREATE TABLE download_grants (
resource_id BLOB NOT NULL,
item_id BLOB NOT NULL,
endpoint_hash BLOB NOT NULL,
PRIMARY KEY (resource_id, item_id, endpoint_hash)
) WITHOUT ROWID;
CREATE INDEX ix_download_grants_resource ON download_grants(resource_id);
""";
private sealed class SqliteTx(SqliteConnection conn) : IResourceTx
{
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes";
@@ -319,6 +330,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
public void DeleteShareRows(Id16 id)
{
byte[] key = id.ToArray();
Run("DELETE FROM download_grants WHERE resource_id=@id", ("@id", key));
Run("DELETE FROM share_frames WHERE share_id=@id", ("@id", key));
Run("DELETE FROM share_items WHERE share_id=@id", ("@id", key));
Run("DELETE FROM share_visitors WHERE share_id=@id", ("@id", key));
@@ -351,10 +363,19 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@s", i.ShareId.ToArray()), ("@i", i.ItemId.ToArray()));
public void DeleteShareItemRows(Id16 shareId, Id16 itemId)
{
Run("DELETE FROM download_grants WHERE resource_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
Run("DELETE FROM share_frames WHERE share_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
Run("DELETE FROM share_items WHERE share_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
}
public bool HasDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash) => Scalar(
"SELECT 1 FROM download_grants WHERE resource_id=@r AND item_id=@i AND endpoint_hash=@e",
("@r", resourceId.ToArray()), ("@i", itemId.ToArray()), ("@e", endpointHash.ToArray())) is not null;
public void AddDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash) => Run(
"INSERT OR IGNORE INTO download_grants (resource_id,item_id,endpoint_hash) VALUES (@r,@i,@e)",
("@r", resourceId.ToArray()), ("@i", itemId.ToArray()), ("@e", endpointHash.ToArray()));
private static ShareFrame MapShareFrame(SqliteDataReader r) => new(
IdAt(r, 0), IdAt(r, 1), (uint)r.GetInt64(2), r.GetInt64(3), r.GetInt32(4), Blob(r, 5), BlobOrNull(r, 6),
r.IsDBNull(7) ? null : r.GetString(7));
@@ -411,6 +432,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
public void DeleteChannelRows(Id16 id)
{
byte[] key = id.ToArray();
Run("DELETE FROM download_grants WHERE resource_id=@c", ("@c", key));
Run("DELETE FROM frames WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM log WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM items WHERE channel_id=@c", ("@c", key));
@@ -476,6 +498,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
public void DeleteItemRows(Id16 channelId, Id16 itemId)
{
byte[] c = channelId.ToArray(), i = itemId.ToArray();
Run("DELETE FROM download_grants WHERE resource_id=@c AND item_id=@i", ("@c", c), ("@i", i));
Run("DELETE FROM frames WHERE channel_id=@c AND item_id=@i", ("@c", c), ("@i", i));
// 墓碑保留,其余记录(帧、announce)随条目一起删除
Run("DELETE FROM log WHERE channel_id=@c AND item_id=@i AND kind<>@t", ("@c", c), ("@i", i), ("@t", (int)LogKind.Tombstone));
@@ -1,5 +1,7 @@
using System.Net;
using System.Security.Cryptography;
using System.Text.Json;
using WebDrop.Api;
using WebDrop.Core;
using static WebDrop.Api.Tests.ServerFixture;
@@ -17,6 +19,7 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
var json = await ReadJsonAsync(resp);
Assert.Equal(1, json.GetProperty("protocol").GetInt32());
Assert.Equal(ApiContract.Version, json.GetProperty("contract_version").GetString());
Assert.True(json.GetProperty("transports").GetProperty("websocket").GetBoolean());
Assert.Equal(1048592, json.GetProperty("limits").GetProperty("max_payload_bytes").GetInt32());
// 默认不启用验证码,不下发 Cap 的地址
@@ -89,6 +92,31 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "not_joined");
}
[Fact]
public async Task OwnerCapabilityCanManageWithoutOccupyingAnotherSeat()
{
var ch = await s.CreateAsync();
using (var joined = await s.JoinAsync(ch, ch.Member)) Assert.Equal(HttpStatusCode.OK, joined.StatusCode);
var unseatedOwner = new Who("owner", ch.OwnerToken, RandomNumberGenerator.GetBytes(32));
async Task<HttpResponseMessage> OwnerRequest(HttpMethod method, string path, HttpContent? body = null)
{
using var request = s.Req(method, path, unseatedOwner, body);
request.Headers.Remove("X-WD-Endpoint");
return await s.Http.SendAsync(request);
}
using (var patch = await OwnerRequest(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", JsonBody(new { seats = 3 })))
Assert.Equal(HttpStatusCode.OK, patch.StatusCode);
using (var seats = await OwnerRequest(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats"))
Assert.Equal(HttpStatusCode.OK, seats.StatusCode);
using (var absentSeat = await OwnerRequest(HttpMethod.Delete,
$"/api/v1/channels/{ch.Id}/seats/{B64(RandomNumberGenerator.GetBytes(32))}"))
await AssertErrorAsync(absentSeat, 404, "not_found");
using (var deleted = await OwnerRequest(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}"))
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner)), 404, "not_found");
}
[Fact]
public async Task AppendIsIdempotentOverHttp()
{
@@ -287,6 +315,7 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
public async Task DeleteChannelRemovesIt()
{
var ch = await s.CreateAsync();
using (var joined = await s.JoinAsync(ch, ch.Member)) Assert.Equal(HttpStatusCode.OK, joined.StatusCode);
await s.AppendAsync(ch, ch.Owner, Small(NewId()));
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}", ch.Member)), 403, "forbidden");
@@ -119,6 +119,37 @@ public sealed class ShareTests(ServerFixture server) : IClassFixture<ServerFixtu
await AssertErrorAsync(shareCollision, 409, "conflict");
}
[Fact]
public async Task ResumeWithoutAnInitialDownloadCannotBypassShareDownloadLimit()
{
var share = new ShareClient(NewId());
using (var created = await CreateShare(share)) Assert.Equal(HttpStatusCode.Created, created.StatusCode);
string manifestId = NewId();
using (var manifest = await SendFrame(share, share.Owner, Frame(manifestId, 0, FinalEnc, EncPayload)))
Assert.Equal(HttpStatusCode.Created, manifest.StatusCode);
string itemId = NewId();
using (var first = await SendFrame(share, share.Owner, Frame(itemId, 0, GatedEnc, EncPayload), "1"))
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
using (var last = await SendFrame(share, share.Owner, Frame(itemId, 1, GatedFinalEnc, EncPayload)))
Assert.Equal(HttpStatusCode.Created, last.StatusCode);
using (var finish = await server.Http.SendAsync(server.Req(HttpMethod.Post,
$"/api/v1/shares/{share.Id}/finish", share.Owner, JsonBody(new { manifest_item_id = manifestId }))))
Assert.Equal(HttpStatusCode.OK, finish.StatusCode);
string path = $"/api/v1/shares/{share.Id}/items/{itemId}/frames";
await AssertErrorAsync(await server.Http.SendAsync(server.Req(HttpMethod.Get, path + "?from=1", share.OtherVisitor)), 410, "gone");
var (initialResponse, initialBytes) = await server.GetBytesAsync(path, share.Visitor);
using (initialResponse) Assert.Equal(Frame(itemId, 0, GatedEnc, EncPayload, 0x42).Concat(Frame(itemId, 1, GatedFinalEnc, EncPayload, 0x42)), initialBytes);
var (resumeResponse, resumedBytes) = await server.GetBytesAsync(path + "?from=1", share.Visitor);
using (resumeResponse) Assert.Equal(Frame(itemId, 1, GatedFinalEnc, EncPayload, 0x42), resumedBytes);
using var infoResponse = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/shares/{share.Id}", share.CreatorReader));
var item = (await ReadJsonAsync(infoResponse)).GetProperty("items").EnumerateArray().Single();
Assert.Equal(0, item.GetProperty("downloads_left").GetInt32());
}
private async Task<HttpResponseMessage> CreateShare(ShareClient share)
{
using var req = server.Req(HttpMethod.Post, "/api/v1/shares", share.Owner, JsonBody(new
@@ -114,6 +114,26 @@ public class ChannelServiceTests : IDisposable
await AssertCode(ErrorCode.NotJoined, () => LogOf(ch, ch.Reader(_memberEp)));
}
[Fact]
public async Task ItemIdsAndFramesAreScopedToTheirChannel()
{
var otherOwnerEndpoint = TestEnv.Endpoint();
var first = _env.Create(_ownerEp);
var second = _env.Create(otherOwnerEndpoint);
var sharedItemId = TestEnv.NewId();
var firstOnlyItemId = TestEnv.NewId();
await AppendSmall(first, first.Owner(_ownerEp), sharedItemId, fill: 0x11);
await AppendSmall(second, second.Owner(otherOwnerEndpoint), sharedItemId, fill: 0x22);
await AppendSmall(first, first.Owner(_ownerEp), firstOnlyItemId, fill: 0x33);
var firstFrame = (await Svc.ReadItemFramesAsync(first.Id, first.Owner(_ownerEp), sharedItemId, 0, 10)).Frames.Single();
var secondFrame = (await Svc.ReadItemFramesAsync(second.Id, second.Owner(otherOwnerEndpoint), sharedItemId, 0, 10)).Frames.Single();
Assert.Equal(0x11, firstFrame[^1]);
Assert.Equal(0x22, secondFrame[^1]);
await AssertCode(ErrorCode.NotFound,
() => Svc.ReadItemFramesAsync(second.Id, second.Owner(otherOwnerEndpoint), firstOnlyItemId, 0, 10));
}
[Fact]
public async Task ReleasedSeatLosesAccess()
{
@@ -143,6 +163,22 @@ public class ChannelServiceTests : IDisposable
Assert.Equal(2, seats.Count);
}
[Fact]
public void OwnerCapabilityCanManageWithoutOccupyingAnotherSeat()
{
var ch = _env.Create(_ownerEp);
Svc.Join(ch.Id, ch.Reader(_memberEp));
var unseatedOwner = ch.Owner(TestEnv.Endpoint());
// Owner management uses A; an owner on a new device can manage a full channel without first taking a seat.
Assert.Equal(ch.Id.ToBase64Url(), Svc.GetChannel(ch.Id, unseatedOwner).Id);
Assert.Equal(3, Svc.UpdateSettings(ch.Id, unseatedOwner, new ChannelSettingsPatch { Seats = 3 }).Settings.Seats);
Assert.Equal(2, Svc.ListSeats(ch.Id, unseatedOwner).Count);
AssertCode(ErrorCode.NotFound, () => Svc.ReleaseSeat(ch.Id, unseatedOwner, TestEnv.Endpoint()));
Svc.DeleteChannel(ch.Id, unseatedOwner);
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
}
[Fact]
public void SeatLabelIsStoredAndBounded()
{
@@ -361,6 +397,7 @@ public class ChannelServiceTests : IDisposable
await Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload));
await Append(ch, o, TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload)); // 乱序到达,缺 1
Svc.StartItemDownload(ch.Id, o, item, 0);
var chunk = await Svc.ReadItemFramesAsync(ch.Id, o, item, 0, 10);
Assert.Single(chunk.Frames);
Assert.Equal(1u, chunk.NextIndex);
@@ -403,7 +440,7 @@ public class ChannelServiceTests : IDisposable
Assert.Single((await Svc.ReadItemFramesAsync(ch.Id, ch.Reader(_memberEp), item, 1, 10)).Frames);
// 宽限期过后拒绝,清理任务删除密文并留下墓碑
_env.Clock.Advance(TimeSpan.FromSeconds(_env.Limits.DownloadGraceSeconds + 1));
_env.Clock.Advance(TimeSpan.FromSeconds(_env.Limits.DownloadGraceSeconds));
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 1));
Assert.Equal(1, Svc.Sweep());
@@ -412,6 +449,29 @@ public class ChannelServiceTests : IDisposable
AssertCode(ErrorCode.NotFound, () => Svc.StartItemDownload(ch.Id, o, item, 0));
}
[Fact]
public async Task ResumeNeedsACountedDownloadFromTheSameEndpoint()
{
var otherEndpoint = TestEnv.Endpoint();
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { Seats = 3, FileMaxDownloads = 1 });
Svc.Join(ch.Id, ch.Reader(_memberEp));
Svc.Join(ch.Id, ch.Reader(otherEndpoint));
var item = await UploadGated(ch, ch.Owner(_ownerEp), 2);
var otherItem = await UploadGated(ch, ch.Owner(_ownerEp), 2);
var reader = ch.Reader(_memberEp);
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, reader, item, 1));
await AssertCode(ErrorCode.Gone, () => Svc.ReadItemFramesAsync(ch.Id, reader, item, 1, 10));
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, ch.Reader(otherEndpoint), item, 1));
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, reader, otherItem, 1));
Svc.StartItemDownload(ch.Id, reader, item, 0);
Svc.StartItemDownload(ch.Id, reader, item, 1);
var resumed = await Svc.ReadItemFramesAsync(ch.Id, reader, item, 1, 10);
Assert.Single(resumed.Frames);
Assert.Equal(2u, resumed.NextIndex);
}
[Fact]
public async Task ZeroMaxDownloadsMeansUnlimitedAndHeaderOverridesDefault()
{
@@ -506,9 +566,16 @@ public class ChannelServiceTests : IDisposable
await AppendSmall(ch, o, old);
_env.Clock.Advance(TimeSpan.FromHours(12));
await AppendSmall(ch, o); // 续期频道,新条目 TTL 从现在起算
var fresh = TestEnv.NewId();
await AppendSmall(ch, o, fresh); // 续期频道,新条目 TTL 从现在起算
_env.Clock.Advance(TimeSpan.FromHours(13)); // 第一个条目已过期,第二个还在
var beforeCleanup = await Svc.ReadLogAsync(ch.Id, o, 0, 100);
Assert.Single(beforeCleanup.Records);
Assert.Equal(fresh.ToArray(), beforeCleanup.Records[0].Bytes[(9 + 2)..(9 + 2 + 16)]);
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, o, old, 0));
await AssertCode(ErrorCode.Gone, () => Svc.ReadItemFramesAsync(ch.Id, o, old, 0, 10));
Assert.Equal(1, Svc.Sweep());
var log = await Svc.ReadLogAsync(ch.Id, o, 0, 100);
+25
View File
@@ -8,6 +8,8 @@
"name": "@webdrop/web",
"version": "0.0.0",
"dependencies": {
"@webdrop/core": "file:../packages/core",
"cap-widget": "^0.1.58",
"idb": "^8.0.3",
"react": "^19.3.0",
"react-dom": "^19.3.0",
@@ -25,6 +27,19 @@
"vitest": "^5.0.3"
}
},
"../packages/core": {
"name": "@webdrop/core",
"version": "0.1.0",
"license": "UNLICENSED",
"devDependencies": {
"@types/node": "^26.6.3",
"typescript": "^7.0.2",
"vitest": "^5.0.3"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1074,6 +1089,10 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@webdrop/core": {
"resolved": "../packages/core",
"link": true
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -1184,6 +1203,12 @@
"node": ">=6"
}
},
"node_modules/cap-widget": {
"version": "0.1.58",
"resolved": "https://registry.npmjs.org/cap-widget/-/cap-widget-0.1.58.tgz",
"integrity": "sha512-7iS/Q++AaiVvWR8xvMSxRwGWY+fV1ZADJNwPzLk4P1gibCmCbS5G6MGOtoUjsj9OxI9+0nkewW6gRsOGDCOZZA==",
"license": "Apache-2.0"
},
"node_modules/chai": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
+2
View File
@@ -12,6 +12,8 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@webdrop/core": "file:../packages/core",
"cap-widget": "^0.1.58",
"idb": "^8.0.3",
"react": "^19.3.0",
"react-dom": "^19.3.0",
+11 -1
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Home } from '@/features/home/Home';
import { JoinScreen, MissingAccess } from '@/features/scan/JoinScreen';
import { useChannels } from '@/store/channels';
import { useShares } from '@/store/shares';
import { useWorkspace } from '@/store/workspace';
import { ChannelScreen } from './ChannelScreen';
import { ShareScreen } from './ShareScreen';
@@ -21,14 +22,21 @@ function splashSeen(): boolean {
export function App() {
const route = useRoute();
const list = useChannels((s) => s.list);
const channelsHydrated = useChannels((s) => s.hydrated);
const pendingLink = useChannels((s) => s.pendingLink);
const setPendingLink = useChannels((s) => s.setPendingLink);
const sharesHydrated = useShares((s) => s.hydrated);
const pendingShareLink = useShares((s) => s.pendingLink);
const wsReady = useWorkspace((s) => s.ready);
const [splash, setSplash] = useState(() => !splashSeen());
const saved = route.name === 'channel' && list.some((c) => c.id === route.id);
// 只有打开已保存的频道才需要等数据;首页与加入页没有加载过程
const ready = !saved || wsReady;
const waitingOnSavedChannel = route.name === 'channel' && !pendingLink && !channelsHydrated;
const waitingOnSavedShare = route.name === 'share' && pendingShareLink?.id !== route.id && !sharesHydrated;
const ready = route.name === 'home' || (route.name === 'channel'
? pendingLink?.id === route.id || (channelsHydrated && (!saved || wsReady))
: pendingShareLink?.id === route.id || sharesHydrated);
function onSplashDone() {
try {
@@ -41,7 +49,9 @@ export function App() {
let screen;
if (route.name === 'home') screen = <Home />;
else if (route.name === 'share' && waitingOnSavedShare) screen = null;
else if (route.name === 'share') screen = <ShareScreen id={route.id} />;
else if (route.name === 'channel' && waitingOnSavedChannel) screen = null;
else if (saved) screen = <ChannelScreen id={route.id} />;
else if (pendingLink?.id === route.id) {
screen = (
+29
View File
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { takeLinkFromLocation } from './entry';
const ID = 'k3Fq9xWm2LpA7sTdQ8vZ1n';
const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
afterEach(() => vi.unstubAllGlobals());
describe('takeLinkFromLocation', () => {
it('在解析 fragment 密钥前同步清除地址栏 fragment', () => {
const location = new URL(`https://webdrop.example/c/${ID}#k=${KEY}`);
const replaceState = vi.fn();
vi.stubGlobal('location', location);
vi.stubGlobal('history', { replaceState });
expect(takeLinkFromLocation()).toMatchObject({ kind: 'channel', link: { id: ID, keys: { k: KEY } } });
expect(replaceState).toHaveBeenCalledWith(null, '', `/c/${ID}`);
});
it('拒绝 query 中的密钥并同步移除,不再继续使用这个链接', () => {
const location = new URL(`https://webdrop.example/s/${ID}?keep=1&k=${KEY}`);
const replaceState = vi.fn();
vi.stubGlobal('location', location);
vi.stubGlobal('history', { replaceState });
expect(takeLinkFromLocation()).toBeNull();
expect(replaceState).toHaveBeenCalledWith(null, '', `/s/${ID}?keep=1`);
});
});
+8 -2
View File
@@ -10,9 +10,15 @@ export type EntryLink =
* 必须在任何异步代码和渲染之前同步调用。
*/
export function takeLinkFromLocation(): EntryLink | null {
if (!location.hash) return null;
const query = new URLSearchParams(location.search);
const queryHasSecret = ['k', 'a', 'w'].some((name) => query.has(name));
if (!location.hash && !queryHasSecret) return null;
const href = location.href;
history.replaceState(null, '', location.pathname + location.search);
for (const name of ['k', 'a', 'w']) query.delete(name);
const search = query.toString();
history.replaceState(null, '', `${location.pathname}${search ? `?${search}` : ''}`);
// query 在浏览器导航时已发给托管端;拒绝继续使用,避免它进入后续链接或请求。
if (queryHasSecret) return null;
const share = parseShareLink(href);
if (share.ok) return { kind: 'share', link: share.link };
+4 -1
View File
@@ -7,9 +7,12 @@ import type { ArchiveEntry, ChannelInfo, ConnectionState, CreateOptions, Workspa
*/
export interface WorkspaceApi {
/** 新建频道:本地生成 id 与密钥、算 PoW、POST channels(api.md) */
createChannel(opts: CreateOptions): Promise<ChannelInfo>;
createChannel(opts: CreateOptions, captchaToken?: string): Promise<ChannelInfo>;
/** 通过链接加入:先 GET 频道信息预览,再 POST seats */
joinChannel(link: ParsedChannelLink): Promise<ChannelInfo>;
listSaved(): Promise<ChannelInfo[]>;
/** 仅从本机删除保存的链接和终端密钥,不会删服务端频道 */
forget(id: string): Promise<void>;
/** 仅假实现提供:载入演示用的频道 */
loadDemo?(): ChannelInfo[];
+25
View File
@@ -0,0 +1,25 @@
const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
interface ServerInfo {
captcha?: { enabled?: boolean; api_endpoint?: string };
}
/** Cap performs challenge solving in its own workers and only returns a one-use token. */
export async function solveCreationCaptcha(): Promise<string | undefined> {
const response = await fetch(`${API_BASE.replace(/\/$/, '')}/info`, {
cache: 'no-store',
credentials: 'omit',
referrerPolicy: 'no-referrer',
});
if (!response.ok) throw new Error('无法读取服务端能力');
const info = await response.json() as ServerInfo;
if (!info.captcha?.enabled) return undefined;
if (typeof info.captcha.api_endpoint !== 'string') throw new Error('验证码服务配置无效');
// Cap's official client owns the challenge protocol and WASM worker implementation; bundling it avoids a runtime CDN script.
const { default: Cap } = await import('cap-widget');
const config = { apiEndpoint: info.captcha.api_endpoint, workers: 2 } as ConstructorParameters<typeof Cap>[0] & { workers: number };
const { token } = await new Cap(config).solve();
if (!token) throw new Error('验证码未完成');
return token;
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { supportsApiContract } from './contract';
describe('API contract compatibility', () => {
it('accepts compatible SemVer releases in the supported major', () => {
expect(supportsApiContract('1.0.0')).toBe(true);
expect(supportsApiContract('1.8.2')).toBe(true);
expect(supportsApiContract('1.2.0-rc.1')).toBe(true);
});
it('rejects a breaking major and malformed versions', () => {
expect(supportsApiContract('2.0.0')).toBe(false);
expect(supportsApiContract('01.0.0')).toBe(false);
expect(supportsApiContract(undefined)).toBe(false);
});
});
+9
View File
@@ -0,0 +1,9 @@
export const SUPPORTED_API_CONTRACT_MAJOR = 1;
const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
export function supportsApiContract(version: unknown): boolean {
if (typeof version !== 'string') return false;
const match = semverPattern.exec(version);
return match !== null && Number(match[1]) === SUPPORTED_API_CONTRACT_MAJOR;
}
+143
View File
@@ -0,0 +1,143 @@
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
import type { Item } from './types';
export type ManifestEntry =
| { t: 'file'; id: string; name: string; mime: string; size: number; ts: number }
| { t: 'text'; localId: string; name?: string; text: string; ts: number };
export interface SavedResource {
id: string;
kind: 'channel' | 'share';
mode: 'enc' | 'raw';
/** Keys stay in IndexedDB. The page URL and localStorage never retain them. */
link: string;
ownerLink?: string;
endpointSecret: Uint8Array;
role: 'owner' | 'member';
mine?: boolean;
createdAt: number;
expiresAt: number;
seats: number;
membersCanUpload: boolean;
state?: 'draft' | 'sealed';
maxVisitors?: number;
maxDownloads?: number | null;
ttlSeconds?: number;
pendingManifestId?: string;
localItems?: Item[];
draftItems?: Item[];
manifestEntries?: ManifestEntry[];
}
export interface QueuedFrame {
key: string;
resourceId: string;
itemId: string;
index: number;
bytes: Uint8Array;
extraHeaders: Record<string, string>;
createdAt: number;
}
interface DraftFile {
key: string;
resourceId: string;
itemId: string;
file: File;
}
interface WebDropDb extends DBSchema {
resources: { key: string; value: SavedResource };
outbox: { key: string; value: QueuedFrame; indexes: { 'by-resource': string } };
draftFiles: { key: string; value: DraftFile; indexes: { 'by-resource': string } };
}
let dbPromise: Promise<IDBPDatabase<WebDropDb>> | undefined;
function db(): Promise<IDBPDatabase<WebDropDb>> {
dbPromise ??= openDB<WebDropDb>('webdrop-local', 2, {
upgrade(database, oldVersion) {
if (oldVersion < 1) {
database.createObjectStore('resources', { keyPath: 'id' });
const outbox = database.createObjectStore('outbox', { keyPath: 'key' });
outbox.createIndex('by-resource', 'resourceId');
}
if (oldVersion < 2) {
const draftFiles = database.createObjectStore('draftFiles', { keyPath: 'key' });
draftFiles.createIndex('by-resource', 'resourceId');
}
},
});
return dbPromise;
}
export async function saveResource(resource: SavedResource): Promise<void> {
await (await db()).put('resources', resource);
}
export async function getResource(id: string): Promise<SavedResource | undefined> {
return (await db()).get('resources', id);
}
export async function listResources(kind: SavedResource['kind']): Promise<SavedResource[]> {
const values = await (await db()).getAll('resources');
return values.filter((resource) => resource.kind === kind).sort((a, b) => b.createdAt - a.createdAt);
}
export async function removeResource(id: string): Promise<void> {
const database = await db();
const tx = database.transaction(['resources', 'outbox', 'draftFiles'], 'readwrite');
await tx.objectStore('resources').delete(id);
const keys = await tx.objectStore('outbox').index('by-resource').getAllKeys(id);
await Promise.all(keys.map((key) => tx.objectStore('outbox').delete(key)));
const fileKeys = await tx.objectStore('draftFiles').index('by-resource').getAllKeys(id);
await Promise.all(fileKeys.map((key) => tx.objectStore('draftFiles').delete(key)));
await tx.done;
}
export async function saveDraftFile(resourceId: string, itemId: string, file: File): Promise<void> {
const key = `${resourceId}:${itemId}`;
await (await db()).put('draftFiles', { key, resourceId, itemId, file });
}
export async function getDraftFile(resourceId: string, itemId: string): Promise<File | undefined> {
return (await db()).get('draftFiles', `${resourceId}:${itemId}`).then((record) => record?.file);
}
export async function removeDraftFile(resourceId: string, itemId: string): Promise<void> {
await (await db()).delete('draftFiles', `${resourceId}:${itemId}`);
}
export async function clearDraftFiles(resourceId: string): Promise<void> {
const database = await db();
const tx = database.transaction('draftFiles', 'readwrite');
const keys = await tx.store.index('by-resource').getAllKeys(resourceId);
await Promise.all(keys.map((key) => tx.store.delete(key)));
await tx.done;
}
export async function queueFrame(frame: QueuedFrame): Promise<void> {
await (await db()).put('outbox', frame);
}
export async function queuedFrames(resourceId: string): Promise<QueuedFrame[]> {
const frames = await (await db()).getAllFromIndex('outbox', 'by-resource', resourceId);
return frames.sort((a, b) => a.createdAt - b.createdAt || a.itemId.localeCompare(b.itemId) || a.index - b.index);
}
export async function dequeueFrame(key: string): Promise<void> {
await (await db()).delete('outbox', key);
}
export async function nextQueuedFrame(resourceId: string): Promise<QueuedFrame | undefined> {
const values = await (await db()).getAllFromIndex('outbox', 'by-resource', resourceId, 1);
return values[0];
}
export async function dequeueItem(resourceId: string, itemId: string): Promise<void> {
const database = await db();
const tx = database.transaction('outbox', 'readwrite');
const frames = await tx.store.index('by-resource').getAll(resourceId);
await Promise.all(frames.filter((frame) => frame.itemId === itemId).map((frame) => tx.store.delete(frame.key)));
await tx.done;
}
+20
View File
@@ -0,0 +1,20 @@
const messages: Record<string, string> = {
not_found: '内容不存在或已过期。', gone: '内容已删除、下载次数已用完或续传已失效。',
seats_full: '频道席位已满,请联系所有者。', not_joined: '你已被移出此频道。',
visitors_full: '分享的访客名额已满。', unauthorized: '链接权限无效,请使用完整链接重新打开。',
forbidden: '此链接没有执行此操作的权限。', sealed: '分享已封存,无法再修改。',
too_large: '内容超过服务端容量或条目数量限制。', rate_limited: '请求过于频繁,请稍后重试。',
captcha_required: '验证码未通过或已失效,请重新创建。',
captcha_unavailable: '验证码服务暂时不可用,请稍后重试。',
api_contract_mismatch: '服务端 API 有破坏性更新,请刷新或更新前端后再试。',
mode_mismatch: '频道加密模式与链接不符,已拒绝访问。', conflict: '上传内容发生冲突,已停止发送。',
};
export function errorMessage(error: unknown): string {
if (!(error instanceof Error)) return '操作失败,请重试。';
const mapped = messages[error.message];
if (mapped) return mapped;
// 不回显来自服务端或解析器的原始错误,它们可能包含密钥或明文。
return /[\u4e00-\u9fff]/.test(error.message) && error.message.length < 100
? error.message : '操作失败,请重试。';
}
+3 -7
View File
@@ -1,8 +1,4 @@
import type { WorkspaceApi } from './api';
import { createMockApi } from './mock';
import { createMockShareApi } from './mockShare';
import type { ShareApi } from './shareApi';
import { realApi, realShareApi } from './real';
/** 接真实后端时,只改这两行 */
export const api: WorkspaceApi = createMockApi();
export const shareApi: ShareApi = createMockShareApi();
export const api = realApi;
export const shareApi = realShareApi;
+5 -1
View File
@@ -82,10 +82,13 @@ export function createMockApi(): WorkspaceApi {
}
return {
async listSaved() { return []; },
async forget() {},
async createChannel(opts) {
await wait(300);
const id = b64url(16);
const info: ChannelInfo = { id, mode: opts.mode, role: 'owner', seats: opts.seats, createdAt: Date.now(), ...buildLinks(id, opts) };
const createdAt = Date.now();
const info: ChannelInfo = { id, mode: opts.mode, role: 'owner', seats: opts.seats, createdAt, expiresAt: createdAt + opts.ttlSeconds * 1000, ...buildLinks(id, opts) };
newRuntime(info, [selfDevice]);
return info;
},
@@ -104,6 +107,7 @@ export function createMockApi(): WorkspaceApi {
role: link.isOwner ? 'owner' : 'member',
seats: 3,
createdAt: Date.now(),
expiresAt: Date.now() + 86400_000,
memberLink: member ? `${base}#${member}` : base,
ownerLink: link.isOwner ? `${base}#${frag(link.keys)}` : undefined,
};
+1
View File
@@ -20,6 +20,7 @@ export const channel: ChannelInfo = {
role: 'owner',
seats: 3,
createdAt: Date.now() - 3 * 3600_000,
expiresAt: Date.now() + 21 * 3600_000,
memberLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}`,
ownerLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}&a=${KEY}`,
};
+14 -2
View File
@@ -39,8 +39,9 @@ export function createMockShareApi(): ShareApi {
mine: false,
link: `${location.origin}/s/${DEMO_SHARE_ID}#k=${DEMO_KEY}`,
createdAt: Date.now() - 2 * 3600_000,
expiresAt: Date.now() + 22 * 3600_000,
ttlSeconds: 86400,
seats: 10,
maxVisitors: 10,
maxDownloads: 5,
};
const rt: Runtime = { share, items: new Map(), blobs: new Map() };
@@ -59,6 +60,8 @@ export function createMockShareApi(): ShareApi {
}
return {
async listSaved() { return []; },
async forget() {},
async createDraft(settings) {
await wait(300);
const id = b64url(16);
@@ -68,6 +71,7 @@ export function createMockShareApi(): ShareApi {
mine: true,
link: `${location.origin}/s/${id}#k=${b64url(32)}`,
createdAt: Date.now(),
expiresAt: Date.now() + 86400_000,
...settings,
};
runtimes.set(id, { share, items: new Map(), blobs: new Map() });
@@ -107,8 +111,9 @@ export function createMockShareApi(): ShareApi {
mine: false,
link: `${location.origin}/s/${link.id}#k=${link.key}`,
createdAt: Date.now(),
expiresAt: Date.now() + 86400_000,
ttlSeconds: 86400,
seats: 10,
maxVisitors: 10,
maxDownloads: null,
};
runtimes.set(link.id, { share, items: new Map(), blobs: new Map() });
@@ -143,6 +148,13 @@ export function createMockShareApi(): ShareApi {
put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text });
},
async editText(id, text) {
const rt = draftOnly();
const item = rt.items.get(id);
if (!item || item.kind !== 'text') throw new Error('not_found');
put({ ...item, text, ts: Date.now() });
},
async addFiles(files) {
const rt = draftOnly();
for (const file of files) {
+85
View File
@@ -0,0 +1,85 @@
import type { ParsedChannelLink } from '@/lib/channelLink';
import type { ParsedShareLink } from '@/lib/shareLink';
import type { WorkspaceApi } from './api';
import type { ShareApi } from './shareApi';
import { callWorker, subscribeWorker } from './workerClient';
import type { ArchiveEntry, ChannelInfo, ConnectionState, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types';
const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
function reportChannelUnavailable(id: string, onEvent: (event: WorkspaceEvent) => void): void {
onEvent({ t: 'connection', state: 'offline' });
onEvent({ t: 'ready' });
void id;
}
export const realApi: WorkspaceApi = {
createChannel: (options: CreateOptions, captchaToken?: string) => callWorker<ChannelInfo>('createChannel', { options, captchaToken, apiBase: API_BASE }),
joinChannel: (link: ParsedChannelLink) => callWorker<ChannelInfo>('joinChannel', { link, apiBase: API_BASE }),
listSaved: () => callWorker<ChannelInfo[]>('listChannels', { apiBase: API_BASE }),
forget: (id) => callWorker<void>('forgetChannel', { id, apiBase: API_BASE }),
connect(channelId, onEvent) {
activeChannelId = channelId;
activeShareId = '';
const unsubscribe = subscribeWorker<WorkspaceEvent>(channelId, onEvent);
void callWorker<boolean>('connectChannel', { id: channelId, apiBase: API_BASE }).catch(() => reportChannelUnavailable(channelId, onEvent));
return () => {
unsubscribe();
void callWorker('stopChannel', { id: channelId, apiBase: API_BASE });
};
},
sendText: (text) => callWorker<void>('sendText', { id: currentChannelId(), text, apiBase: API_BASE }),
sendFiles: (files) => callWorker<void>('sendFiles', { id: currentChannelId(), files, apiBase: API_BASE }),
editText: (id, text) => callWorker<void>('editChannelText', { id: currentChannelId(), itemId: id, text, apiBase: API_BASE }),
resolveConflict: (id, choice) => callWorker<void>('resolveTextConflict', { id: currentChannelId(), itemId: id, choice, apiBase: API_BASE }),
remove: (id) => callWorker<void>('removeChannelItem', { id: currentChannelId(), itemId: id, apiBase: API_BASE }),
setSnapshot: (text) => callWorker<void>('updateSnapshot', { id: currentChannelId(), text, apiBase: API_BASE }),
getBlob: (id, versionId) => callWorker<Blob>('getChannelBlob', { id: currentChannelId(), itemId: id, versionId, apiBase: API_BASE }),
listArchive: (_id: string): Promise<ArchiveEntry[]> => callWorker<ArchiveEntry[]>('listArchive', { id: currentChannelId(), apiBase: API_BASE }),
markSeen: (id) => callWorker<void>('markChannelSeen', { id: currentChannelId(), itemId: id, apiBase: API_BASE }),
};
export const realShareApi: ShareApi = {
createDraft: (settings: ShareSettings, captchaToken?: string) => callWorker<Share>('createShare', { settings, captchaToken, apiBase: API_BASE }),
listSaved: () => callWorker<Share[]>('listShares', { apiBase: API_BASE }),
updateSettings: (id, patch) => callWorker<Share>('updateShareSettings', { id, patch, apiBase: API_BASE }),
seal: (id) => callWorker<Share>('sealShare', { id, apiBase: API_BASE }),
join: (link: ParsedShareLink) => callWorker<Share>('joinShare', { link, apiBase: API_BASE }),
forget: (id) => callWorker<void>('forgetShare', { id, apiBase: API_BASE }),
connect(shareId, onEvent) {
activeShareId = shareId;
activeChannelId = '';
const unsubscribe = subscribeWorker<ShareEvent>(shareId, onEvent);
void callWorker<void>('connectShare', { id: shareId, apiBase: API_BASE }).catch((error: unknown) => {
onEvent({ t: 'error', message: error instanceof Error ? error.message : '无法打开分享' });
onEvent({ t: 'ready' });
});
return () => {
unsubscribe();
void callWorker('stopShare', { id: shareId, apiBase: API_BASE });
};
},
addText: (text) => callWorker<void>('addShareText', { id: currentShareId(), text, apiBase: API_BASE }),
editText: (id, text) => callWorker<void>('editShareText', { id: currentShareId(), itemId: id, text, apiBase: API_BASE }),
addFiles: (files) => callWorker<void>('addShareFiles', { id: currentShareId(), files, apiBase: API_BASE }),
removeItem: (id) => callWorker<void>('removeShareItem', { id: currentShareId(), itemId: id, apiBase: API_BASE }),
getBlob: (id) => callWorker<Blob>('getShareBlob', { id: currentShareId(), itemId: id, apiBase: API_BASE }),
listArchive: (_id: string): Promise<ArchiveEntry[]> => callWorker<ArchiveEntry[]>('listArchive', { id: currentShareId(), apiBase: API_BASE }),
};
let activeChannelId = '';
let activeShareId = '';
function currentChannelId(): string {
return activeChannelId;
}
function currentShareId(): string {
return activeShareId;
}
export function simulateConnection(_state: ConnectionState): void {}
+5 -1
View File
@@ -6,8 +6,9 @@ import type { ArchiveEntry, Share, ShareEvent, ShareSettings } from './types';
* 「草稿 → 封存 → 过期」。线路上仍是 channels(成员不可上传),所以由同一个 core 会话实现。
*/
export interface ShareApi {
listSaved(): Promise<Share[]>;
/** 新建草稿:创建者握着所有者密钥。此时还没有可分享的链接 */
createDraft(settings: ShareSettings): Promise<Share>;
createDraft(settings: ShareSettings, captchaToken?: string): Promise<Share>;
/** 草稿阶段调整设置(PATCH settings,需所有者令牌) */
updateSettings(id: string, patch: Partial<ShareSettings>): Promise<Share>;
/**
@@ -17,6 +18,8 @@ export interface ShareApi {
seal(id: string): Promise<Share>;
/** 访客通过链接直接加入(不预览、不展示人数),返回这个分享 */
join(link: ParsedShareLink): Promise<Share>;
/** 仅从本机移除链接和终端密钥,不会删除已封存的远端分享 */
forget(id: string): Promise<void>;
/** 仅假实现提供:载入演示用的分享 */
loadDemo?(): Share[];
@@ -24,6 +27,7 @@ export interface ShareApi {
connect(shareId: string, onEvent: (e: ShareEvent) => void): () => void;
/** 草稿阶段添加与移除内容 */
addText(text: string): Promise<void>;
editText(id: string, text: string): Promise<void>;
addFiles(files: File[]): Promise<void>;
removeItem(id: string): Promise<void>;
/** 取内容(封存后每次拉取消耗一次下载,见 api.md) */
+11 -3
View File
@@ -23,6 +23,8 @@ export interface ChannelInfo {
/** 只有所有者有 */
ownerLink?: string;
createdAt: number;
expiresAt: number;
canUpload?: boolean;
}
/** 新建频道的选项(protocol §12 设置的子集) */
@@ -46,9 +48,10 @@ export interface Share {
/** 访客链接 `/s/:id#k=…`。草稿阶段不展示 */
link: string;
createdAt: number;
expiresAt: number;
ttlSeconds: number;
/** 最多访客(创建者的设置,不向访客展示) */
seats: number;
maxVisitors: number;
/** 每个文件最多下载次数,null 为不限(protocol §12 file_max_downloads) */
maxDownloads: number | null;
}
@@ -56,7 +59,7 @@ export interface Share {
/** 草稿阶段可调整的设置 */
export interface ShareSettings {
ttlSeconds: number;
seats: number;
maxVisitors: number;
maxDownloads: number | null;
}
@@ -90,6 +93,7 @@ export interface FileItem extends ItemBase {
/** 剩余下载次数,null 为不限(protocol §12 file_max_downloads) */
remaining: number | null;
preview: PreviewKind;
gated?: boolean;
/**
* 旧版本,新的在前,不含当前版本。
* 约定(待写入 protocol §7):新版本是一个新条目,其加密的 meta_json 带 `replaces`,
@@ -123,6 +127,8 @@ export interface Snapshot {
}
export type WorkspaceEvent =
| { t: 'reset' }
| { t: 'error'; message: string }
| { t: 'ready' }
| { t: 'connection'; state: ConnectionState }
| { t: 'channel'; info: ChannelInfo }
@@ -135,4 +141,6 @@ export type ShareEvent =
| { t: 'ready' }
| { t: 'share'; share: Share }
| { t: 'item'; item: Item }
| { t: 'removed'; id: string };
| { t: 'removed'; id: string }
| { t: 'integrity'; message: string }
| { t: 'error'; message: string };
+74
View File
@@ -0,0 +1,74 @@
import { setApiBase } from './worker/http';
import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems';
import { createChannel, forgetChannel, joinChannel, listChannels } from './worker/channelResources';
import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend';
import { stopChannel } from './worker/channelTransport';
import { addShareFiles, addShareText, editShareText, removeShareItem } from './worker/shareDraft';
import { connectShare, getShareBlob, stopShare } from './worker/shareRead';
import { createShare, joinShare, listSavedShares, removeShare, updateShareSettings } from './worker/shareResources';
import { sealShare } from './worker/shareSeal';
import { setEventSink } from './worker/port';
import { serial } from './worker/serial';
import { errorMessage } from './errors';
interface WorkerRequest { requestId: number; operation: string; payload: unknown }
interface WorkerScope {
onmessage: ((event: MessageEvent<WorkerRequest>) => void) | null;
postMessage(message: unknown, transfer?: Transferable[]): void;
}
const scope = self as unknown as WorkerScope;
setEventSink((resourceId, event) => scope.postMessage({ resourceId, event }));
async function dispatch(operation: string, input: unknown): Promise<unknown> {
const payload = input as Record<string, unknown>;
if (typeof payload.apiBase === 'string') setApiBase(payload.apiBase);
const id = typeof payload.id === 'string' ? payload.id : '';
switch (operation) {
case 'createChannel': return createChannel(payload.options as Parameters<typeof createChannel>[0], typeof payload.captchaToken === 'string' ? payload.captchaToken : undefined);
case 'joinChannel': return joinChannel(payload as Parameters<typeof joinChannel>[0]);
case 'listChannels': return listChannels();
case 'forgetChannel': return forgetChannel(id);
case 'connectChannel': void connectChannelResource(id); return true;
case 'stopChannel': stopChannel(id); return true;
case 'sendText': return sendText(id, String(payload.text ?? ''));
case 'sendFiles': return sendFiles(id, payload.files as File[]);
case 'removeChannelItem': return removeChannelItem(id, String(payload.itemId));
case 'getChannelBlob': return getChannelBlob(id, String(payload.itemId), typeof payload.versionId === 'string' ? payload.versionId : undefined);
case 'updateSnapshot': return updateSnapshot(id, String(payload.text ?? ''));
case 'editChannelText': return editChannelText(id, String(payload.itemId), String(payload.text ?? ''));
case 'resolveTextConflict': return resolveTextConflict();
case 'markChannelSeen': return markChannelSeen();
case 'listArchive': return listArchive(id);
case 'createShare': return createShare(payload.settings as Parameters<typeof createShare>[0], typeof payload.captchaToken === 'string' ? payload.captchaToken : undefined);
case 'joinShare': return joinShare(payload as Parameters<typeof joinShare>[0]);
case 'listShares': return listSavedShares();
case 'updateShareSettings': return updateShareSettings(id, payload.patch as Parameters<typeof updateShareSettings>[1]);
case 'sealShare': return sealShare(id);
case 'connectShare': return connectShare(id);
case 'stopShare': stopShare(id); return true;
case 'addShareText': return addShareText(id, String(payload.text ?? ''));
case 'editShareText': return editShareText(id, String(payload.itemId), String(payload.text ?? ''));
case 'addShareFiles': return addShareFiles(id, payload.files as File[]);
case 'removeShareItem': return removeShareItem(id, String(payload.itemId));
case 'getShareBlob': return getShareBlob(id, String(payload.itemId));
case 'forgetShare': return removeShare(id);
default: throw new Error('未知的前端操作');
}
}
scope.onmessage = (event) => {
const request = event.data;
const payload = request.payload as { id?: string; link?: { id: string } };
const key = payload.id ?? payload.link?.id;
const readOnly = /^(connect|stop|list|get)/.test(request.operation);
const task = key && !readOnly ? serial(key, () => dispatch(request.operation, request.payload))
: dispatch(request.operation, request.payload);
void task.then(
(result) => scope.postMessage({ requestId: request.requestId, result }),
(error: unknown) => scope.postMessage({
requestId: request.requestId,
error: errorMessage(error),
}),
);
};
+131
View File
@@ -0,0 +1,131 @@
import { parseLogRecords, toBase64Url, type ChannelSession, type LogRecord, type ReceivedItem } from '@webdrop/core';
import { getResource, saveResource, type SavedResource } from '../database';
import type { FileItem, Item, Snapshot } from '../types';
import { previewKindOf } from '@/lib/fileKind';
import { blobs, readers } from './sessions';
import { showRejected, deviceIdentity, deviceInfo, publishWorkspace } from './presentation';
import { type ChannelWire, toChannelInfo } from './wire';
import { apiFetch, readJson } from './http';
export function mapReceived(item: ReceivedItem): Item {
const timestamp = item.meta?.ts ?? Date.now();
const deviceId = item.meta?.dev ?? '未知设备';
const state = item.complete ? { kind: 'complete' as const } : { kind: 'incomplete' as const };
if (item.kind === 'file' || item.kind === 'raw') {
const name = item.meta?.name ?? '加密文件';
return {
id: item.itemId, kind: 'file', ts: timestamp, deviceId, state, name,
mime: item.meta?.mime ?? 'application/octet-stream', size: item.meta?.size ?? item.content.byteLength,
remaining: null, preview: previewKindOf(item.meta?.mime ?? '', name),
gated: item.gated,
};
}
return { id: item.itemId, kind: 'text', ts: timestamp, deviceId, state, text: new TextDecoder().decode(item.content) };
}
export async function applyChannelRecord(resource: SavedResource, session: ChannelSession, input: Uint8Array | LogRecord): Promise<void> {
let reader = readers.get(resource.id);
if (!reader) {
reader = session.createItemReader();
readers.set(resource.id, reader);
}
const events = await reader.push(input);
for (const event of events) {
switch (event.type) {
case 'item': {
const item = mapReceived(event.item);
if (event.item.snapshot) {
let snapshot: Snapshot = { text: '', updatedBy: item.deviceId, updatedAt: item.ts };
if (item.kind === 'text') {
try {
const value = JSON.parse(item.text) as Partial<Snapshot>;
if (typeof value.text === 'string') snapshot = { text: value.text, updatedBy: value.updatedBy ?? item.deviceId, updatedAt: value.updatedAt ?? item.ts };
else snapshot.text = item.text;
} catch { snapshot.text = item.text; }
}
publishWorkspace(resource.id, { t: 'snapshot', snapshot });
} else {
const blob = item.kind === 'file' && event.item.complete ? new Blob([event.item.content], { type: item.mime }) : undefined;
if (blob) blobs.set(`${resource.id}:${item.id}`, blob);
publishWorkspace(resource.id, { t: 'item', item });
}
break;
}
case 'announce': {
const local = (await getResource(resource.id))?.localItems?.find((item) => item.id === event.itemId);
const placeholder: FileItem = local?.kind === 'file' ? local : {
id: event.itemId, kind: 'file', ts: event.createdAt, deviceId: '未知设备', state: { kind: 'incomplete' },
name: '受限下载内容', mime: 'application/octet-stream', size: 0, remaining: null, preview: 'none',
gated: true,
};
publishWorkspace(resource.id, { t: 'item', item: placeholder });
break;
}
case 'removed':
blobs.delete(`${resource.id}:${event.itemId}`);
publishWorkspace(resource.id, { t: 'removed', id: event.itemId });
break;
case 'rejected':
showRejected(resource.id, event);
break;
default: {
const exhaustive: never = event;
throw new Error(`未知的加密事件 ${String(exhaustive)}`);
}
}
}
}
export async function channelMetadata(resource: SavedResource, session: ChannelSession): Promise<void> {
const info = await readJson<ChannelWire>(`/channels/${resource.id}`, session.authHeaders('read'));
session.verifyChannelInfo(info);
resource.membersCanUpload = info.settings.members_can_upload;
resource.expiresAt = info.expires_at;
const latest = await getResource(resource.id);
await saveResource({ ...latest, ...resource, localItems: latest?.localItems });
publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) });
const ownId = toBase64Url(session.endpointHash);
let devices = [deviceInfo(ownId, Date.now(), deviceIdentity().name, ownId)];
if (session.can('owner')) {
const seats = await readJson<Array<{ endpoint_hash: string; joined_at: number; label?: string }>>(
`/channels/${resource.id}/seats`, session.authHeaders('owner'));
devices = await Promise.all(seats.map(async (seat) => {
let name: string | undefined;
if (seat.label) {
try { name = await session.openLabel(seat.label, seat.endpoint_hash); }
catch { name = '设备名校验失败'; }
}
return deviceInfo(seat.endpoint_hash, seat.joined_at, name, ownId);
}));
const own = devices.find((device) => device.self);
if (own) own.name = deviceIdentity().name;
}
publishWorkspace(resource.id, { t: 'devices', devices });
}
export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise<number> {
publishWorkspace(resource.id, { t: 'reset' });
readers.set(resource.id, session.createItemReader());
let from = 0;
for (;;) {
const response = await apiFetch(`/channels/${resource.id}/log?from=${from}&limit=200`, {
headers: session.authHeaders('read'), signal,
});
const records = parseLogRecords(new Uint8Array(await response.arrayBuffer()));
for (const record of records) {
await applyChannelRecord(resource, session, record);
from = Math.max(from, record.seq + 1);
}
if (records.length < 200) return from;
}
}
export async function processChannelBytes(resource: SavedResource, session: ChannelSession, bytes: Uint8Array): Promise<number> {
const records = parseLogRecords(bytes);
let next = 0;
for (const record of records) {
await applyChannelRecord(resource, session, record);
next = Math.max(next, record.seq + 1);
}
return next;
}
@@ -0,0 +1,62 @@
import { previewKindOf } from '@/lib/fileKind';
import type { FileItem } from '../types';
import { apiFetch } from './http';
import { blobs, getSession } from './sessions';
import { stopChannel, connectChannel } from './channelTransport';
import { downloadDecrypted } from './transfer';
import { publishWorkspace } from './presentation';
import { IntegrityError } from '@webdrop/core';
export function connectChannelResource(id: string): () => void {
void connectChannel(id);
return () => stopChannel(id);
}
export async function removeChannelItem(resourceId: string, itemId: string): Promise<void> {
const { session } = await getSession(resourceId);
const role = session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read';
await apiFetch(`/channels/${resourceId}/items/${itemId}`, {
method: 'DELETE', headers: session.authHeaders(role),
});
}
export async function getChannelBlob(resourceId: string, itemId: string, versionId?: string): Promise<Blob> {
if (versionId) throw new Error('此版本暂不可下载');
const cached = blobs.get(`${resourceId}:${itemId}`);
if (cached) return cached;
const { session, resource } = await getSession(resourceId);
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
let result: Awaited<ReturnType<typeof downloadDecrypted>>;
try { result = await downloadDecrypted(session, resource, itemId); }
catch (error) {
if (error instanceof IntegrityError) publishWorkspace(resourceId, { t: 'item', item: { id: itemId, kind: 'text', ts: Date.now(), deviceId: '', text: '', state: { kind: 'tampered' } } });
throw error;
}
blobs.set(`${resourceId}:${itemId}`, result.blob);
if (result.meta?.kind === 'file') {
const item: FileItem = {
id: itemId, kind: 'file', ts: result.meta.ts, deviceId: result.meta.dev ?? '未知设备', state: { kind: 'complete' },
name: result.meta.name ?? '加密文件', mime: result.meta.mime ?? result.blob.type, size: result.blob.size,
remaining: null, preview: 'none', gated: true,
};
item.preview = previewKindOf(item.mime, item.name);
publishWorkspace(resourceId, { t: 'item', item });
}
return result.blob;
}
export async function resolveTextConflict(): Promise<void> {
throw new Error('此条目没有可合并的冲突版本');
}
export async function markChannelSeen(): Promise<void> {
return undefined;
}
export async function listArchive(_id: string): Promise<never[]> {
throw new Error('服务端没有提供压缩包目录接口,可以直接下载。');
}
@@ -0,0 +1,95 @@
import { ChannelSession } from '@webdrop/core';
import { getResource, listResources, removeResource, saveResource, type SavedResource } from '../database';
import type { ParsedChannelLink } from '@/lib/channelLink';
import type { ChannelInfo, CreateOptions } from '../types';
import { apiFetch, readJson } from './http';
import { sessions } from './sessions';
import { deviceIdentity } from './presentation';
import { makeLink } from './links';
import { toChannelInfo, type ChannelWire } from './wire';
import { stopChannel } from './channelTransport';
export async function createChannel(options: CreateOptions, captchaToken?: string): Promise<ChannelInfo> {
const endpointSecret = crypto.getRandomValues(new Uint8Array(32));
const { session, request } = await ChannelSession.create({
mode: options.mode,
endpointSecret,
settings: {
seats: options.seats,
ttl_seconds: options.ttlSeconds,
members_can_upload: options.membersCanUpload,
delete_policy: 'own',
max_items: 1000,
file_max_downloads: 10,
ui_restrict_copy_download: false,
},
});
try {
const info = await apiFetch('/channels', {
method: 'POST', headers: { ...request.headers, 'Content-Type': 'application/json', ...(captchaToken ? { 'X-WD-Cap': captchaToken } : {}) }, body: JSON.stringify(request.body),
}).then((response) => response.json() as Promise<ChannelWire>);
session.verifyChannelInfo(info);
const resource: SavedResource = {
id: session.id,
kind: 'channel', mode: session.mode, role: 'owner',
link: session.link(self.location.origin), ownerLink: session.link(self.location.origin, 'owner'),
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload,
maxDownloads: info.settings.file_max_downloads, localItems: [],
};
await saveResource(resource);
sessions.set(session.id, session);
return toChannelInfo(info, resource);
} catch (error) {
session.dispose();
throw error;
}
}
export async function joinChannel(input: { link: ParsedChannelLink }): Promise<ChannelInfo> {
const { id, keys } = input.link;
const existing = await getResource(id);
const endpointSecret = existing?.endpointSecret ?? crypto.getRandomValues(new Uint8Array(32));
const session = await ChannelSession.open(makeLink(self.location.origin, id, keys), { endpointSecret });
try {
const info = await readJson<ChannelWire>(`/channels/${id}`, session.authHeaders('read'));
session.verifyChannelInfo(info);
await apiFetch(`/channels/${id}/seats`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read') });
const resource: SavedResource = {
id, kind: 'channel', mode: session.mode, role: session.role,
link: session.link(self.location.origin), ownerLink: session.can('owner') ? session.link(self.location.origin, 'owner') : undefined,
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload,
maxDownloads: info.settings.file_max_downloads, localItems: existing?.localItems ?? [],
};
const label = await session.sealLabel(deviceIdentity().name);
await apiFetch(`/channels/${id}/seats/me`, {
method: 'PUT', headers: { ...session.authHeaders('read'), 'Content-Type': 'application/octet-stream' }, body: label,
});
await saveResource(resource);
sessions.set(id, session);
return toChannelInfo(info, resource);
} catch (error) {
session.dispose();
throw error;
}
}
export async function listChannels(): Promise<ChannelInfo[]> {
const saved = await listResources('channel');
return saved.map((resource) => ({
id: resource.id, mode: resource.mode, role: resource.role, seats: resource.seats,
memberLink: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
}));
}
export async function forgetChannel(id: string): Promise<void> {
stopChannel(id);
sessions.get(id)?.dispose();
sessions.delete(id);
await removeResource(id);
}
+71
View File
@@ -0,0 +1,71 @@
import { removeChannelItem } from './channelItems';
import { saveResource } from '../database';
import { previewKindOf } from '@/lib/fileKind';
import type { FileItem, Item } from '../types';
import { blobs, getSession } from './sessions';
import { deviceIdentity } from './presentation';
import { flushOutbox, persistPlan } from './transfer';
import { publishWorkspace } from './presentation';
export async function sendText(resourceId: string, text: string): Promise<{ itemId: string; uploaded: boolean }> {
const { session, resource } = await getSession(resourceId);
if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限');
const device = deviceIdentity();
session.deviceName = device.name;
const ts = Date.now();
const plan = session.encryptText(text, { dev: device.name, ts });
const item: Item = { id: plan.itemId, kind: 'text', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0 }, text };
blobs.set(`${resourceId}:${item.id}`, new Blob([text], { type: 'text/plain' }));
publishWorkspace(resourceId, { t: 'item', item });
await persistPlan(resource, plan);
const uploaded = await flushOutbox(resource, session);
publishWorkspace(resourceId, { t: 'item', item: { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } } });
if (!uploaded) publishWorkspace(resourceId, { t: 'connection', state: 'offline' });
return { itemId: plan.itemId, uploaded };
}
export async function sendFiles(resourceId: string, files: File[]): Promise<void> {
const { session, resource } = await getSession(resourceId);
if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限');
const device = deviceIdentity();
session.deviceName = device.name;
for (const file of files) {
const ts = Date.now();
const plan = session.encryptFile(file, {}, { maxDownloads: resource.maxDownloads ?? 10, dev: device.name, ts });
let item: FileItem = {
id: plan.itemId, kind: 'file', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0 },
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
remaining: resource.maxDownloads || null, preview: previewKindOf(file.type, file.name), gated: true,
};
resource.localItems = [...(resource.localItems ?? []).filter((entry) => entry.id !== item.id), item];
await saveResource(resource);
blobs.set(`${resourceId}:${item.id}`, file);
publishWorkspace(resourceId, { t: 'item', item });
await persistPlan(resource, plan);
const uploaded = await flushOutbox(resource, session);
item = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } };
resource.localItems = resource.localItems.map((entry) => entry.id === item.id ? item : entry);
await saveResource(resource);
publishWorkspace(resourceId, { t: 'item', item });
}
}
export async function updateSnapshot(resourceId: string, text: string): Promise<void> {
const { session, resource } = await getSession(resourceId);
const plan = session.encryptJson({ text, updatedBy: deviceIdentity().name, updatedAt: Date.now() }, { snapshot: true, dev: deviceIdentity().name });
await persistPlan(resource, plan);
await flushOutbox(resource, session);
}
export async function editChannelText(resourceId: string, itemId: string, text: string): Promise<void> {
const created = await sendText(resourceId, text);
if (!created.uploaded) throw new Error('新版本已排队,网络恢复前保留旧内容。');
await removeChannelItem(resourceId, itemId);
}
@@ -0,0 +1,80 @@
import { IntegrityError } from '@webdrop/core';
import { active, getSession } from './sessions';
import { apiFetch, type ApiError } from './http';
import { publishWorkspace } from './presentation';
import { channelMetadata, processChannelBytes, syncChannel } from './channelEvents';
import { flushOutbox } from './transfer';
import { socketStream, sseStream, delay, type StreamConsumer } from './transports';
import { errorMessage } from '../errors';
export async function connectChannel(id: string): Promise<void> {
active.get(id)?.abort();
const controller = new AbortController();
active.set(id, controller);
const signal = controller.signal;
let flushTimer: ReturnType<typeof setInterval> | undefined;
try {
const { session, resource } = await getSession(id);
if (signal.aborted) return;
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
const stream: StreamConsumer = {
cursor: 0,
async record(bytes) { stream.cursor = Math.max(stream.cursor, await processChannelBytes(resource, session, bytes)); },
async control(value) { if (value.t === 'settings' || value.t === 'seats') await channelMetadata(resource, session); },
online() { if (!signal.aborted) publishWorkspace(id, { t: 'connection', state: 'online' }); },
};
let initialized = false;
let transport = 0;
let backoff = 1000;
flushTimer = setInterval(() => {
if (!signal.aborted) void flushOutbox(resource, session).catch((error: unknown) => {
publishWorkspace(id, { t: 'error', message: errorMessage(error) });
});
}, 2500);
while (!signal.aborted) {
try {
if (!initialized) {
stream.cursor = await syncChannel(resource, session, signal);
if (signal.aborted) break;
await channelMetadata(resource, session);
await flushOutbox(resource, session);
initialized = true;
publishWorkspace(id, { t: 'ready' });
}
stream.online();
if (transport === 0) await socketStream(id, session, signal, stream);
else if (transport === 1) await sseStream(id, session, signal, stream);
else {
const response = await apiFetch(`/channels/${id}/log?from=${stream.cursor}&limit=200&wait=25`, { headers: session.authHeaders('read'), signal });
await stream.record(new Uint8Array(await response.arrayBuffer()));
}
backoff = 1000;
if (transport < 2) transport++;
} catch (error) {
if (signal.aborted) break;
const code = (error as ApiError).status;
if (code === 410) { initialized = false; continue; }
if (code === 401 || code === 403 || code === 404 || error instanceof IntegrityError) throw error;
if (transport < 2 && initialized) { transport++; continue; }
publishWorkspace(id, { t: 'connection', state: 'offline' });
publishWorkspace(id, { t: 'ready' });
await delay(backoff + Math.random() * backoff / 3, signal);
backoff = Math.min(30_000, backoff * 2);
}
}
} catch (error) {
if (!signal.aborted) {
publishWorkspace(id, { t: 'error', message: error instanceof IntegrityError ? '内容校验失败,已停止接收。' : errorMessage(error) });
publishWorkspace(id, { t: 'connection', state: 'offline' });
publishWorkspace(id, { t: 'ready' });
}
} finally {
clearInterval(flushTimer);
if (active.get(id) === controller) active.delete(id);
}
}
export function stopChannel(id: string): void {
active.get(id)?.abort();
active.delete(id);
}
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { apiFetch, setApiBase } from './http';
describe('API contract preflight', () => {
afterEach(() => {
setApiBase('/api/v1');
vi.unstubAllGlobals();
});
it('blocks API requests when the server has a newer breaking major', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '2.0.0' }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
setApiBase('http://api.test/api/v1');
await expect(apiFetch('/channels')).rejects.toThrow('api_contract_mismatch');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('http://api.test/api/v1/info', expect.any(Object));
});
it('checks the contract once and then sends the API request', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '1.2.0' }), { status: 200 }))
.mockResolvedValueOnce(new Response('{}', { status: 200 }))
.mockResolvedValueOnce(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
setApiBase('http://api.test/api/v1');
await expect(apiFetch('/channels')).resolves.toBeInstanceOf(Response);
await expect(apiFetch('/shares')).resolves.toBeInstanceOf(Response);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});
+58
View File
@@ -0,0 +1,58 @@
import { supportsApiContract } from '../contract';
export type ApiError = Error & { status?: number; code?: string };
let apiBase = '/api/v1';
let contractCheck: Promise<void> | undefined;
export function setApiBase(value: string): void {
if (apiBase === value) return;
apiBase = value;
contractCheck = undefined;
}
export const fail = (status: number, code: string): ApiError => Object.assign(new Error(code), { status, code });
export const apiPath = (path: string): string => `${apiBase.replace(/\/$/, '')}${path}`;
function ensureApiContract(): Promise<void> {
contractCheck ??= fetch(apiPath('/info'), {
cache: 'no-store',
credentials: 'omit',
referrerPolicy: 'no-referrer',
}).then(async (response) => {
if (!response.ok) throw new Error('api_info_unavailable');
const info = await response.json() as { contract_version?: unknown };
if (!supportsApiContract(info.contract_version)) throw new Error('api_contract_mismatch');
}).catch((error: unknown) => {
contractCheck = undefined;
throw error;
});
return contractCheck;
}
export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
try {
if (path !== '/info') await ensureApiContract();
const response = await fetch(apiPath(path), {
...init,
cache: 'no-store',
credentials: 'omit',
referrerPolicy: 'no-referrer',
});
if (!response.ok) {
const body = await response.json().catch(() => null) as { error?: unknown } | null;
const code = typeof body?.error === 'string' && /^[a-z_]{1,40}$/.test(body.error) ? body.error : 'request_failed';
throw fail(response.status, code);
}
return response;
} catch (error) {
if (init.signal?.aborted) throw new DOMException('请求已取消', 'AbortError');
if (error instanceof Error && 'status' in error) throw error;
if (error instanceof Error && error.message === 'api_contract_mismatch') throw error;
throw new Error('网络连接失败');
}
}
export async function readJson<T>(path: string, headers: Record<string, string>): Promise<T> {
return apiFetch(path, { headers }).then((response) => response.json() as Promise<T>);
}
+17
View File
@@ -0,0 +1,17 @@
export function toCoreLink(link: string): string {
const parsed = new URL(link);
if (parsed.pathname.startsWith('/s/')) parsed.pathname = parsed.pathname.replace(/^\/s\//, '/c/');
return parsed.href;
}
export function toShareLink(link: string): string {
const parsed = new URL(link);
parsed.pathname = parsed.pathname.replace(/^\/c\//, '/s/');
return parsed.href;
}
export function makeLink(origin: string, id: string, keys: Record<string, string | undefined>): string {
const fragment = Object.entries(keys).filter((entry): entry is [string, string] => Boolean(entry[1]))
.map(([key, value]) => `${key}=${value}`).join('&');
return `${origin.replace(/\/$/, '')}/c/${id}${fragment ? `#${fragment}` : ''}`;
}
+14
View File
@@ -0,0 +1,14 @@
import type { ShareEvent, WorkspaceEvent } from '../types';
type BridgeEvent = WorkspaceEvent | ShareEvent;
type EventSink = (resourceId: string, event: BridgeEvent, blob?: Blob) => void;
let sink: EventSink = () => {};
export function setEventSink(next: EventSink): void {
sink = next;
}
export function publish(resourceId: string, event: BridgeEvent, blob?: Blob): void {
sink(resourceId, event, blob);
}
@@ -0,0 +1,30 @@
import type { ItemEvent } from '@webdrop/core';
import type { Device, FileItem, ShareEvent, WorkspaceEvent } from '../types';
import { previewKindOf } from '@/lib/fileKind';
import { publish } from './port';
export function deviceIdentity(): { name: string; kind: Device['kind'] } {
const mobile = /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent);
return { name: mobile ? '本机 · 移动设备' : '本机 · 浏览器', kind: mobile ? 'phone' : 'desktop' };
}
export function deviceInfo(id: string, joinedAt: number, label: string | undefined, selfId: string): Device {
return { id, name: label ?? '未知设备', kind: 'desktop', online: id === selfId, self: id === selfId, lastSeen: joinedAt };
}
export function showRejected(resourceId: string, event: Extract<ItemEvent, { type: 'rejected' }>, share = false): void {
if (share) publish(resourceId, { t: 'integrity', message: '分享清单校验失败,内容已拒绝显示。' });
else if (event.itemId) publish(resourceId, {
t: 'item', item: { id: event.itemId, kind: 'text', ts: Date.now(), deviceId: '未知设备', state: { kind: 'tampered' }, text: '' },
});
else publish(resourceId, { t: 'error', message: '内容校验失败,已拒绝该记录。' });
}
export function makeFileItem(id: string, name: string, mime: string, size: number, ts: number, state: FileItem['state'], remaining: number | null = null): FileItem {
return { id, kind: 'file', ts, deviceId: '创建者', state, name, mime, size, remaining, preview: previewKindOf(mime, name) };
}
export function publishWorkspace(resourceId: string, event: WorkspaceEvent): void {
publish(resourceId, event);
}
export function publishShare(resourceId: string, event: ShareEvent): void {
publish(resourceId, event);
}
+9
View File
@@ -0,0 +1,9 @@
const running = new Map<string, Promise<unknown>>();
export function serial<T>(key: string, action: () => Promise<T>): Promise<T> {
const previous = running.get(key) ?? Promise.resolve();
const next = previous.catch(() => undefined).then(action);
running.set(key, next);
void next.finally(() => { if (running.get(key) === next) running.delete(key); }).catch(() => undefined);
return next;
}
+25
View File
@@ -0,0 +1,25 @@
import { ChannelSession } from '@webdrop/core';
import { getResource, type SavedResource } from '../database';
import { fail } from './http';
import { toCoreLink } from './links';
export const sessions = new Map<string, ChannelSession>();
export const readers = new Map<string, ReturnType<ChannelSession['createItemReader']>>();
export const active = new Map<string, AbortController>();
export const blobs = new Map<string, Blob>();
const opening = new Map<string, Promise<ChannelSession>>();
export async function getSession(id: string): Promise<{ session: ChannelSession; resource: SavedResource }> {
const resource = await getResource(id);
if (!resource) throw fail(404, 'not_found');
let session = sessions.get(id);
if (!session) {
const sourceLink = resource.ownerLink ?? resource.link;
let task = opening.get(id);
if (!task) {
task = ChannelSession.open(toCoreLink(sourceLink), { endpointSecret: resource.endpointSecret });
opening.set(id, task);
}
try { session = await task; } finally { opening.delete(id); }
sessions.set(id, session);
}
return { session, resource };
}
+99
View File
@@ -0,0 +1,99 @@
import { dequeueItem, removeDraftFile, saveDraftFile, saveResource, type ManifestEntry } from '../database';
import type { FileItem, TextItem } from '../types';
import { makeFileItem } from './presentation';
import { getSession } from './sessions';
import { apiFetch, fail } from './http';
import { flushOutbox, persistPlan } from './transfer';
import { publishShare } from './presentation';
import { invalidateManifest } from './shareState';
import { blobs } from './sessions';
function newTextEntry(text: string): { item: TextItem; entry: Extract<ManifestEntry, { t: 'text' }> } {
const ts = Date.now();
const localId = `draft-text-${crypto.randomUUID()}`;
return {
item: { id: localId, kind: 'text', ts, deviceId: '创建者', state: { kind: 'complete' }, text },
entry: { t: 'text', localId, text, ts },
};
}
export async function addShareText(id: string, text: string): Promise<void> {
const { session, resource } = await getSession(id);
if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存');
await invalidateManifest(session, resource);
const { item, entry } = newTextEntry(text);
resource.draftItems = [...(resource.draftItems ?? []), item];
resource.manifestEntries = [...(resource.manifestEntries ?? []), entry];
await saveResource(resource);
publishShare(id, { t: 'item', item });
}
export async function addShareFiles(id: string, files: File[]): Promise<void> {
const { session, resource } = await getSession(id);
if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存');
await invalidateManifest(session, resource);
const downloadLimit = resource.maxDownloads ?? 0;
for (const file of files) {
const ts = Date.now();
const plan = session.encryptFile(file, { name: file.name, mime: file.type || 'application/octet-stream', size: file.size }, {
gated: true, maxDownloads: downloadLimit, ts,
});
const item = makeFileItem(plan.itemId, file.name, file.type || 'application/octet-stream', file.size, ts, { kind: 'receiving', progress: 0 }, downloadLimit || null);
const entry: Extract<ManifestEntry, { t: 'file' }> = { t: 'file', id: plan.itemId, name: file.name, mime: file.type || 'application/octet-stream', size: file.size, ts };
blobs.set(`${id}:${item.id}`, file);
try { await saveDraftFile(id, item.id, file); }
catch { publishShare(id, { t: 'error', message: '本机存储空间不足,刷新后可能无法预览此文件。' }); }
resource.draftItems = [...(resource.draftItems ?? []), item];
resource.manifestEntries = [...(resource.manifestEntries ?? []), entry];
await saveResource(resource);
publishShare(id, { t: 'item', item });
await persistPlan(resource, plan);
const uploaded = await flushOutbox(resource, session);
const complete: FileItem = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } };
resource.draftItems = resource.draftItems.map((draft) => draft.id === item.id ? complete : draft);
await saveResource(resource);
publishShare(id, { t: 'item', item: complete });
if (!uploaded) {
publishShare(id, { t: 'error', message: '上传暂停了。重新打开草稿并连接网络后,可以继续发送已加密帧。' });
continue;
}
}
}
export async function editShareText(id: string, itemId: string, text: string): Promise<void> {
const { session, resource } = await getSession(id);
if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存');
const item = resource.draftItems?.find((entry): entry is TextItem => entry.id === itemId && entry.kind === 'text');
if (!item) throw fail(404, 'not_found');
await invalidateManifest(session, resource);
const ts = Date.now();
const updated: TextItem = { ...item, text, ts };
resource.draftItems = (resource.draftItems ?? []).map((entry) => entry.id === itemId ? updated : entry);
resource.manifestEntries = (resource.manifestEntries ?? []).map((entry) =>
entry.t === 'text' && entry.localId === itemId ? { ...entry, text, ts } : entry);
await saveResource(resource);
publishShare(id, { t: 'item', item: updated });
}
export async function removeShareItem(id: string, itemId: string): Promise<void> {
const { session, resource } = await getSession(id);
if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存');
await invalidateManifest(session, resource);
const item = resource.draftItems?.find((entry) => entry.id === itemId);
if (!item) throw fail(404, 'not_found');
if (item.kind === 'file') {
try { await apiFetch(`/shares/${id}/items/${itemId}`, { method: 'DELETE', headers: session.authHeaders('owner') }); }
catch (error) { if ((error as { status?: number }).status !== 404) throw error; }
await dequeueItem(id, itemId);
await removeDraftFile(id, itemId);
blobs.delete(`${id}:${itemId}`);
}
resource.draftItems = (resource.draftItems ?? []).filter((entry) => entry.id !== itemId);
resource.manifestEntries = (resource.manifestEntries ?? []).filter((entry) => entry.t === 'file' ? entry.id !== itemId : entry.localId !== itemId);
await saveResource(resource);
publishShare(id, { t: 'removed', id: itemId });
}
@@ -0,0 +1,70 @@
import { IntegrityError, type ChannelSession } from '@webdrop/core';
import type { ManifestEntry } from '../database';
import type { ShareWire } from './wire';
import { apiFetch } from './http';
export type ManifestFile = { t: 'file'; id: string; name?: string; mime?: string; size: number; ts: number };
export type ManifestText = { t: 'text'; name?: string; text: string; ts: number };
export type ProtocolManifestEntry = ManifestFile | ManifestText;
export interface ShareManifest { v: 1; items: ProtocolManifestEntry[] }
export function encodeManifest(entries: ManifestEntry[]): Uint8Array {
const items: ProtocolManifestEntry[] = entries.map((entry) => entry.t === 'file'
? { t: 'file', id: entry.id, name: entry.name, mime: entry.mime, size: entry.size, ts: entry.ts }
: { t: 'text', ...(entry.name === undefined ? {} : { name: entry.name }), text: entry.text, ts: entry.ts });
return new TextEncoder().encode(JSON.stringify({ v: 1, items }));
}
function safeTime(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
export async function fetchManifest(
session: ChannelSession,
shareId: string,
info: ShareWire,
signal?: AbortSignal,
): Promise<ShareManifest> {
if (!info.manifest_item_id) throw new IntegrityError('bad_meta');
const response = await apiFetch(`/shares/${shareId}/manifest`, { headers: session.authHeaders('read'), signal });
const events = await session.createItemReader().pushFrame(new Uint8Array(await response.arrayBuffer()));
const rejection = events.find((event) => event.type === 'rejected');
if (rejection?.type === 'rejected') throw new IntegrityError(rejection.reason);
const found = events.find((event) => event.type === 'item');
if (!found || found.type !== 'item' || found.item.kind !== 'manifest' || !found.item.complete
|| found.item.itemId !== info.manifest_item_id) throw new IntegrityError('bad_meta');
let value: unknown;
try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(found.item.content)); }
catch { throw new IntegrityError('bad_meta'); }
if (typeof value !== 'object' || value === null || !('v' in value) || value.v !== 1 || !('items' in value) || !Array.isArray(value.items))
throw new IntegrityError('bad_meta');
const listed = new Set<string>();
const validated: ProtocolManifestEntry[] = [];
for (const raw of value.items) {
if (typeof raw !== 'object' || raw === null || !('t' in raw) || !safeTime('ts' in raw ? raw.ts : undefined))
throw new IntegrityError('bad_meta');
if (raw.t === 'text' && 'text' in raw && typeof raw.text === 'string') {
if ('name' in raw && raw.name !== undefined && typeof raw.name !== 'string') throw new IntegrityError('bad_meta');
validated.push({ t: 'text', ...(typeof raw.name === 'string' ? { name: raw.name } : {}), text: raw.text, ts: raw.ts });
continue;
}
if (raw.t === 'file' && 'id' in raw && typeof raw.id === 'string' && /^[A-Za-z0-9_-]{22}$/.test(raw.id)
&& 'size' in raw && typeof raw.size === 'number' && Number.isSafeInteger(raw.size) && raw.size >= 0) {
if (listed.has(raw.id)) throw new IntegrityError('bad_meta');
if ('name' in raw && raw.name !== undefined && typeof raw.name !== 'string') throw new IntegrityError('bad_meta');
if ('mime' in raw && raw.mime !== undefined && typeof raw.mime !== 'string') throw new IntegrityError('bad_meta');
listed.add(raw.id);
validated.push({
t: 'file', id: raw.id,
...(typeof raw.name === 'string' ? { name: raw.name } : {}),
...(typeof raw.mime === 'string' ? { mime: raw.mime } : {}),
size: raw.size, ts: raw.ts,
});
continue;
}
throw new IntegrityError('bad_meta');
}
return { v: 1, items: validated };
}
+122
View File
@@ -0,0 +1,122 @@
import { ChannelSession, IntegrityError } from '@webdrop/core';
import { getDraftFile, saveResource, type SavedResource } from '../database';
import type { FileItem, TextItem } from '../types';
import { makeFileItem } from './presentation';
import { active, blobs, getSession } from './sessions';
import { fail, readJson } from './http';
import { toShareInfo, type ShareWire } from './wire';
import { flushOutbox, downloadDecrypted } from './transfer';
import { publishShare } from './presentation';
import { fetchManifest, type ManifestFile } from './shareManifest';
import { readShareInfo } from './shareState';
import { errorMessage } from '../errors';
export async function connectShare(id: string): Promise<void> {
active.get(id)?.abort();
const controller = new AbortController();
active.set(id, controller);
try {
const opened = await getSession(id);
let { session } = opened;
const { resource } = opened;
if (controller.signal.aborted) return;
if (resource.kind !== 'share') throw new Error('资源类型不匹配');
const raw = await readShareInfo(session, resource);
session = (await getSession(id)).session;
if (controller.signal.aborted) return;
const share = toShareInfo(raw, resource);
resource.state = raw.state;
resource.expiresAt = raw.expires_at;
await saveResource(resource);
publishShare(id, { t: 'share', share });
if (raw.state === 'draft' && resource.mine) {
const resumed = await flushOutbox(resource, session);
if (resumed) resource.draftItems = resource.draftItems?.map((item) => item.state.kind === 'incomplete' ? { ...item, state: { kind: 'complete' } } : item);
await saveResource(resource);
if (controller.signal.aborted) return;
for (const item of resource.draftItems ?? []) publishShare(id, { t: 'item', item });
publishShare(id, { t: 'ready' });
return;
}
await loadSealedManifest(resource, session, raw, controller.signal);
} catch (error) {
if (controller.signal.aborted) return;
publishShare(id, error instanceof IntegrityError
? { t: 'integrity', message: '分享清单校验失败,内容已拒绝显示。' }
: { t: 'error', message: errorMessage(error) });
publishShare(id, { t: 'ready' });
} finally {
if (active.get(id) === controller) active.delete(id);
}
}
export function stopShare(id: string): void {
active.get(id)?.abort();
active.delete(id);
}
async function loadSealedManifest(resource: SavedResource, session: ChannelSession, raw: ShareWire, signal: AbortSignal): Promise<void> {
try {
const manifest = await fetchManifest(session, resource.id, raw, signal);
const byId = new Map(raw.items.map((item) => [item.item_id, item]));
for (let index = 0; index < manifest.items.length; index++) {
const entry = manifest.items[index];
if (!entry) throw new Error('invalid_manifest_item');
if (entry.t === 'text' && typeof entry.text === 'string' && Number.isSafeInteger(entry.ts)) {
const item: TextItem = { id: `manifest-text-${index}`, kind: 'text', ts: entry.ts, deviceId: '创建者', state: { kind: 'complete' }, text: entry.text };
publishShare(resource.id, { t: 'item', item });
} else if (entry.t === 'file' && typeof entry.id === 'string' && typeof entry.size === 'number' && Number.isSafeInteger(entry.size)) {
const detail = byId.get(entry.id);
const item = makeFileItem(entry.id, entry.name ?? '加密文件', entry.mime ?? 'application/octet-stream', entry.size,
entry.ts, { kind: 'complete' }, detail?.downloads_left ?? (detail ? null : 0));
item.gated = true;
publishShare(resource.id, { t: 'item', item });
} else throw new Error('invalid_manifest_item');
}
} catch {
publishShare(resource.id, { t: 'integrity', message: '分享清单格式不正确,内容已拒绝显示。' });
}
publishShare(resource.id, { t: 'ready' });
}
export async function getShareBlob(id: string, itemId: string): Promise<Blob> {
const cached = blobs.get(`${id}:${itemId}`);
if (cached) return cached;
const { session, resource } = await getSession(id);
if (resource.kind !== 'share') throw new Error('资源类型不匹配');
if (resource.state === 'draft' && resource.mine) {
const draftFile = await getDraftFile(id, itemId);
if (draftFile) {
blobs.set(`${id}:${itemId}`, draftFile);
return draftFile;
}
}
const raw = await readJson<ShareWire>(`/shares/${id}`, session.authHeaders('read'));
const manifest = await fetchManifest(session, id, raw);
if (itemId.startsWith('manifest-text-')) {
const entry = manifest.items[Number(itemId.slice(14))];
if (entry?.t !== 'text') throw fail(404, 'not_found');
return new Blob([entry.text], { type: 'text/plain' });
}
const entry = manifest.items.find((candidate): candidate is ManifestFile => candidate.t === 'file' && candidate.id === itemId);
if (!entry) throw fail(404, 'not_found');
let result: Awaited<ReturnType<typeof downloadDecrypted>>;
try { result = await downloadDecrypted(session, resource, itemId, entry.size); }
catch (error) {
if (error instanceof IntegrityError) publishShare(id, { t: 'integrity', message: '文件校验失败,内容已丢弃。' });
throw error;
}
blobs.set(`${id}:${itemId}`, result.blob);
const fileInfo = raw.items.find((value) => value.item_id === itemId);
const remaining = fileInfo?.downloads_left === null || fileInfo?.downloads_left === undefined
? null
: Math.max(0, fileInfo.downloads_left - 1);
const item: FileItem = makeFileItem(itemId, entry.name ?? '加密文件', entry.mime ?? 'application/octet-stream', entry.size, entry.ts,
{ kind: 'complete' }, remaining);
publishShare(id, { t: 'item', item });
return result.blob;
}
@@ -0,0 +1,108 @@
import { ChannelSession } from '@webdrop/core';
import { getResource, listResources, removeResource, saveResource, type SavedResource } from '../database';
import type { Share, ShareSettings } from '../types';
import { active, blobs, getSession, sessions } from './sessions';
import { apiFetch, fail, readJson } from './http';
import { apiShareSettings, toShareInfo, type ShareWire } from './wire';
import { makeLink, toShareLink } from './links';
export async function createShare(settings: ShareSettings, captchaToken?: string): Promise<Share> {
const endpointSecret = crypto.getRandomValues(new Uint8Array(32));
const { session, request } = await ChannelSession.create({ mode: 'enc', endpointSecret });
try {
const body = {
id: request.body.id,
read_hash: request.body.read_hash,
owner_hash: request.body.owner_hash,
settings: apiShareSettings(settings),
};
const response = await apiFetch('/shares', {
method: 'POST', cache: 'no-store',
headers: { 'Content-Type': 'application/json', 'X-WD-Endpoint': request.headers['X-WD-Endpoint']!, ...(captchaToken ? { 'X-WD-Cap': captchaToken } : {}) },
body: JSON.stringify(body),
});
const raw = await response.json() as ShareWire;
const resource: SavedResource = {
id: raw.id, kind: 'share', mode: 'enc', role: 'owner', mine: true,
link: toShareLink(session.link(self.location.origin)),
ownerLink: toShareLink(session.link(self.location.origin, 'owner')),
endpointSecret: Uint8Array.from(session.endpointSecret),
createdAt: raw.created_at, expiresAt: raw.expires_at, seats: 0, membersCanUpload: false,
state: 'draft', maxVisitors: raw.settings.max_visitors,
ttlSeconds: raw.settings.ttl_seconds,
maxDownloads: raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads,
draftItems: [], manifestEntries: [],
};
await saveResource(resource);
sessions.set(raw.id, session);
return toShareInfo(raw, resource);
} catch (error) {
session.dispose();
throw error;
}
}
export async function joinShare(input: { link: { id: string; key: string } }): Promise<Share> {
const existing = await getResource(input.link.id);
const endpointSecret = existing?.endpointSecret ?? crypto.getRandomValues(new Uint8Array(32));
const session = await ChannelSession.open(makeLink(self.location.origin, input.link.id, { k: input.link.key }), { endpointSecret });
try {
const raw = await readJson<ShareWire>(`/shares/${input.link.id}`, session.authHeaders('read'));
if (raw.state !== 'sealed') throw fail(404, 'not_found');
const resource: SavedResource = {
id: input.link.id, kind: 'share', mode: 'enc', role: 'member', mine: false,
link: toShareLink(makeLink(self.location.origin, input.link.id, { k: input.link.key })),
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: raw.created_at, expiresAt: raw.expires_at,
seats: 0, membersCanUpload: false, state: raw.state,
ttlSeconds: raw.settings.ttl_seconds,
maxVisitors: raw.settings.max_visitors, maxDownloads: raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads,
};
await saveResource(resource);
sessions.set(resource.id, session);
return toShareInfo(raw, resource);
} catch (error) {
session.dispose();
throw error;
}
}
export async function updateShareSettings(id: string, patch: Partial<ShareSettings>): Promise<Share> {
const { session, resource } = await getSession(id);
if (resource.kind !== 'share' || !session.can('owner')) throw new Error('此分享不再可修改');
const body: Record<string, number> = {};
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
if (patch.maxVisitors !== undefined) body.max_visitors = patch.maxVisitors;
if (patch.maxDownloads !== undefined) body.file_max_downloads = patch.maxDownloads ?? 0;
const raw = await apiFetch(`/shares/${id}/settings`, {
method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify(body),
}).then((response) => response.json() as Promise<ShareWire>);
resource.expiresAt = raw.expires_at;
resource.ttlSeconds = raw.settings.ttl_seconds;
resource.maxVisitors = raw.settings.max_visitors;
resource.maxDownloads = raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads;
await saveResource(resource);
return toShareInfo(raw, resource);
}
export async function removeShare(id: string): Promise<void> {
active.get(id)?.abort();
active.delete(id);
sessions.get(id)?.dispose();
sessions.delete(id);
for (const key of blobs.keys()) if (key.startsWith(`${id}:`)) blobs.delete(key);
await removeResource(id);
}
export async function listSavedShares(): Promise<Share[]> {
const saved = await listResources('share');
return saved.map((resource) => ({
id: resource.id, state: resource.state ?? 'draft', mine: resource.mine ?? false,
link: resource.link, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
ttlSeconds: resource.ttlSeconds ?? 86400, maxVisitors: resource.maxVisitors ?? 0,
maxDownloads: resource.maxDownloads ?? null,
}));
}
+48
View File
@@ -0,0 +1,48 @@
import { saveResource } from '../database';
import type { Item, Share } from '../types';
import { getSession, sessions } from './sessions';
import { apiFetch } from './http';
import { toShareInfo, type ShareWire } from './wire';
import { flushOutbox, persistPlan } from './transfer';
import { publishShare } from './presentation';
import { encodeManifest } from './shareManifest';
import { readShareInfo, releaseOwner } from './shareState';
export async function sealShare(id: string): Promise<Share> {
const { session, resource } = await getSession(id);
if (resource.kind !== 'share' || resource.state !== 'draft' || !session.can('owner')) throw new Error('此分享已封存');
if (!(await flushOutbox(resource, session))) throw new Error('网络连接失败,请稍后重试');
const before = await readShareInfo(session, resource);
if (before.state === 'sealed') return toShareInfo(before, resource);
if ((resource.draftItems ?? []).some((item) => item.state.kind !== 'complete')) throw new Error('还有内容没有上传完成');
if (!resource.pendingManifestId) {
const bytes = encodeManifest(resource.manifestEntries ?? []);
const plan = session.encryptBytes(bytes, { kind: 'manifest' }, { gated: false });
await persistPlan(resource, plan);
resource.pendingManifestId = plan.itemId;
await saveResource(resource);
}
if (!(await flushOutbox(resource, session))) throw new Error('清单上传暂停,请重试');
let raw: ShareWire;
try {
const response = await apiFetch(`/shares/${id}/finish`, {
method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' },
body: JSON.stringify({ manifest_item_id: resource.pendingManifestId }),
});
raw = await response.json() as ShareWire;
} catch (error) {
raw = await readShareInfo(session, resource).catch(() => { throw error; });
if (raw.state !== 'sealed') throw error;
}
const items = resource.draftItems ?? [];
if (sessions.get(id)?.can('owner')) await releaseOwner(session, resource, raw);
const sealed = toShareInfo(raw, resource);
publishShare(id, { t: 'share', share: sealed });
const remaining = new Map(raw.items.map((item) => [item.item_id, item.downloads_left]));
for (const draft of items) {
const item: Item = draft.kind === 'file' ? { ...draft, remaining: remaining.get(draft.id) ?? null } : draft;
publishShare(id, { t: 'item', item });
}
publishShare(id, { t: 'ready' });
return sealed;
}
+49
View File
@@ -0,0 +1,49 @@
import { ChannelSession } from '@webdrop/core';
import { clearDraftFiles, dequeueItem, saveResource, type SavedResource } from '../database';
import { blobs, sessions } from './sessions';
import { apiFetch, readJson } from './http';
import { toCoreLink } from './links';
import type { ShareWire } from './wire';
export async function readShareInfo(session: ChannelSession, resource: SavedResource): Promise<ShareWire> {
const role = resource.state === 'draft' && session.can('owner') ? 'owner' : 'read';
let raw: ShareWire;
try { raw = await readJson<ShareWire>(`/shares/${resource.id}`, session.authHeaders(role)); }
catch (error) {
if (role !== 'owner' || (error as { status?: number }).status !== 401) throw error;
// finish 响应丢失时,读凭据仍可确认服务端已封存。
raw = await readJson<ShareWire>(`/shares/${resource.id}`, session.authHeaders('read'));
}
if (raw.id !== resource.id) throw new Error('分享信息与链接不符');
if (raw.state === 'sealed' && session.can('owner')) await releaseOwner(session, resource, raw);
return raw;
}
export async function releaseOwner(session: ChannelSession, resource: SavedResource, raw: ShareWire): Promise<void> {
const reader = await ChannelSession.open(toCoreLink(resource.link), { endpointSecret: resource.endpointSecret });
for (const key of blobs.keys()) if (key.startsWith(`${resource.id}:`)) blobs.delete(key);
try { await clearDraftFiles(resource.id); }
catch { /* 清理失败不能让已封存资源继续保留 owner 会话 */ }
session.dispose();
sessions.set(resource.id, reader);
resource.ownerLink = undefined;
resource.role = 'member';
resource.state = 'sealed';
resource.expiresAt = raw.expires_at;
resource.createdAt = raw.sealed_at ?? raw.created_at;
resource.ttlSeconds = raw.settings.ttl_seconds;
resource.pendingManifestId = undefined;
resource.draftItems = [];
resource.manifestEntries = [];
await saveResource(resource);
}
export async function invalidateManifest(session: ChannelSession, resource: SavedResource): Promise<void> {
const id = resource.pendingManifestId;
if (!id) return;
try { await apiFetch(`/shares/${resource.id}/items/${id}`, { method: 'DELETE', headers: session.authHeaders('owner') }); }
catch (error) { if ((error as { status?: number }).status !== 404) throw error; }
await dequeueItem(resource.id, id);
resource.pendingManifestId = undefined;
await saveResource(resource);
}
+74
View File
@@ -0,0 +1,74 @@
import { fromBase64Url, IntegrityError, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core';
import { dequeueFrame, queueFrame, nextQueuedFrame, type SavedResource } from '../database';
import { apiFetch, type ApiError } from './http';
import { serial } from './serial';
export async function persistPlan(resource: SavedResource, plan: ItemPlan): Promise<void> {
const createdAt = Date.now();
for await (const frame of plan.frames) {
await queueFrame({
key: `${resource.id}:${plan.itemId}:${String(frame.index).padStart(10, '0')}`,
resourceId: resource.id,
itemId: plan.itemId,
index: frame.index,
bytes: frame.bytes,
extraHeaders: frame.index === 0 ? { ...plan.frame0Headers } : {},
createdAt: createdAt + frame.index,
});
}
}
function writeRole(session: ChannelSession): 'owner' | 'write' {
if (session.can('owner')) return 'owner';
if (session.can('write')) return 'write';
throw new Error('此链接没有上传权限');
}
/** The outbox contains ciphertext only. Every retry sends these exact bytes. */
export async function flushOutbox(resource: SavedResource, session: ChannelSession): Promise<boolean> {
return serial(`outbox:${resource.id}`, () => flush(resource, session));
}
async function flush(resource: SavedResource, session: ChannelSession): Promise<boolean> {
for (;;) {
const frame = await nextQueuedFrame(resource.id);
if (!frame) return true;
const role = resource.kind === 'share' ? 'owner' : writeRole(session);
const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : `/channels/${resource.id}/frames`;
try {
await apiFetch(path, {
method: 'POST',
headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...frame.extraHeaders },
body: frame.bytes.slice().buffer as ArrayBuffer,
});
await dequeueFrame(frame.key);
} catch (error) {
const apiError = error as ApiError;
if (apiError.status === undefined || apiError.status === 429 || apiError.status >= 500) return false;
throw error;
}
}
}
export async function downloadDecrypted(
session: ChannelSession,
resource: SavedResource,
itemId: string,
expectedSize?: number,
): Promise<{ blob: Blob; meta: ItemDecodeResult['meta'] }> {
const role = session.can('read') ? 'read' : 'owner';
const path = resource.kind === 'share'
? `/shares/${resource.id}/items/${itemId}/frames?from=0`
: `/channels/${resource.id}/items/${itemId}/frames?from=0`;
const response = await apiFetch(path, { headers: session.authHeaders(role) });
if (!response.body) throw new Error('服务端没有返回文件内容');
const expectedId = fromBase64Url(itemId, 16);
if (!expectedId) throw new IntegrityError('bad_meta');
const decoded = session.decryptItemStream(response.body, { itemId: expectedId });
const blobPromise = new Response(decoded.content).blob();
const [result, blob] = await Promise.all([decoded.result, blobPromise]);
if (!result.complete) throw new Error('文件传输不完整');
if (result.meta?.size !== undefined && result.meta.size !== blob.size) throw new IntegrityError('bad_meta');
if (expectedSize !== undefined && expectedSize !== blob.size) throw new IntegrityError('bad_meta');
return { blob, meta: result.meta };
}
+98
View File
@@ -0,0 +1,98 @@
import { fromBase64Url, toBase64Url, type ChannelSession } from '@webdrop/core';
import { apiFetch, apiPath } from './http';
export interface StreamConsumer {
cursor: number;
record(bytes: Uint8Array): Promise<void>;
control(value: { t?: string; seq?: number }): Promise<void>;
online(): void;
}
export function socketStream(id: string, session: ChannelSession, signal: AbortSignal, consumer: StreamConsumer): Promise<void> {
if (signal.aborted) return Promise.resolve();
const address = new URL(apiPath(`/channels/${id}/ws?from=${consumer.cursor}`), self.location.origin);
address.protocol = address.protocol === 'https:' ? 'wss:' : 'ws:';
const token = session.authHeaders('read').Authorization?.slice(7);
const socket = new WebSocket(address, ['wd.v1', 'wd.role.read', ...(token ? [`wd.auth.${token}`] : []), `wd.ep.${toBase64Url(session.endpointSecret)}`]);
socket.binaryType = 'arraybuffer';
return new Promise((resolve, reject) => {
let chain = Promise.resolve();
let failure: unknown;
let timer: ReturnType<typeof setTimeout>;
const heartbeat = () => {
clearTimeout(timer);
timer = setTimeout(() => { failure = new Error('连接超时'); socket.close(); }, 45_000);
};
const abort = () => socket.close();
signal.addEventListener('abort', abort, { once: true });
heartbeat();
socket.onopen = () => consumer.online();
socket.onmessage = (message) => {
heartbeat();
// 建立连接起就排队,异步解密期间不会错过下一帧。
chain = chain.then(async () => {
if (signal.aborted) return;
if (typeof message.data === 'string') await consumer.control(JSON.parse(message.data));
else await consumer.record(new Uint8Array(message.data as ArrayBuffer));
}).catch((error: unknown) => { failure = error; socket.close(); });
};
socket.onerror = () => { failure = new Error('连接失败'); socket.close(); };
socket.onclose = () => {
clearTimeout(timer);
signal.removeEventListener('abort', abort);
void chain.then(() => { if (failure && !signal.aborted) reject(failure); else resolve(); });
};
});
}
export async function sseStream(id: string, session: ChannelSession, signal: AbortSignal, consumer: StreamConsumer): Promise<void> {
const controller = new AbortController();
const abort = () => controller.abort();
signal.addEventListener('abort', abort, { once: true });
let timer = setTimeout(abort, 45_000);
try {
if (signal.aborted) return;
const response = await apiFetch(`/channels/${id}/events?from=${consumer.cursor}`, { headers: session.authHeaders('read'), signal: controller.signal });
if (!response.body) throw new Error('连接失败');
consumer.online();
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (!signal.aborted) {
const chunk = await reader.read();
if (chunk.done) break;
clearTimeout(timer);
timer = setTimeout(abort, 45_000);
buffer += decoder.decode(chunk.value, { stream: true }).replace(/\r/g, '');
let boundary: number;
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
const block = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const lines = block.split('\n');
const event = lines.find((line) => line.startsWith('event:'))?.slice(6).trim();
const data = lines.filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');
if (event === 'log') {
const bytes = fromBase64Url(data);
if (!bytes) throw new Error('内容校验失败');
await consumer.record(bytes);
} else if (event === 'control') await consumer.control(JSON.parse(data));
else if (event === 'error') throw new Error('连接权限已失效');
}
}
} finally { await reader.cancel().catch(() => undefined); reader.releaseLock(); }
} finally {
clearTimeout(timer);
signal.removeEventListener('abort', abort);
controller.abort();
}
}
export function delay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) return resolve();
const finish = () => { clearTimeout(timer); signal.removeEventListener('abort', finish); resolve(); };
const timer = setTimeout(finish, ms);
signal.addEventListener('abort', finish, { once: true });
});
}
+54
View File
@@ -0,0 +1,54 @@
import type { SavedResource } from '../database';
import type { ChannelInfo, Share, ShareSettings } from '../types';
export interface ChannelWire {
id: string;
mode: 'enc' | 'raw';
settings: { seats: number; members_can_upload: boolean; delete_policy: 'owner_only' | 'own' | 'anyone'; ttl_seconds: number; max_items: number; file_max_downloads: number };
seats_used: number;
oldest_seq: number;
last_seq: number;
created_at: number;
expires_at: number;
}
export interface ShareWireItem { item_id: string; bytes: number; frames: number; max_downloads: number; downloads_left: number | null }
export interface ShareWire {
id: string;
state: 'draft' | 'sealed';
expires_at: number;
created_at: number;
sealed_at?: number;
manifest_item_id?: string;
settings: { ttl_seconds: number; max_visitors: number; file_max_downloads: number };
items: ShareWireItem[];
}
export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelInfo {
return {
id: raw.id,
mode: raw.mode,
role: saved.role,
canUpload: saved.role === 'owner' || raw.settings.members_can_upload,
seats: raw.settings.seats,
memberLink: saved.link,
ownerLink: saved.ownerLink,
createdAt: raw.created_at,
expiresAt: raw.expires_at,
};
}
export function toShareInfo(raw: ShareWire, saved: SavedResource): Share {
return {
id: raw.id,
state: raw.state,
mine: saved.mine ?? (saved.role === 'owner' || saved.ownerLink !== undefined),
link: saved.link,
createdAt: raw.sealed_at ?? raw.created_at,
expiresAt: raw.expires_at,
ttlSeconds: raw.settings.ttl_seconds,
maxVisitors: raw.settings.max_visitors,
maxDownloads: raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads,
};
}
export function apiShareSettings(settings: ShareSettings) {
return { ttl_seconds: settings.ttlSeconds, max_visitors: settings.maxVisitors, file_max_downloads: settings.maxDownloads ?? 0 };
}
+72
View File
@@ -0,0 +1,72 @@
import type { ShareEvent, WorkspaceEvent } from './types';
type BridgeEvent = WorkspaceEvent | ShareEvent;
type Pending = { resolve: (value: unknown) => void; reject: (error: Error) => void };
let worker: Worker | undefined;
let nextId = 1;
const pending = new Map<number, Pending>();
const listeners = new Map<string, Set<(event: BridgeEvent, blob?: Blob) => void>>();
function getWorker(): Worker {
worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module', name: 'webdrop-crypto' });
worker.onmessage = (message: MessageEvent<WorkerReply>) => {
const reply = message.data;
if ('requestId' in reply) {
const task = pending.get(reply.requestId);
if (!task) return;
pending.delete(reply.requestId);
if ('error' in reply) task.reject(new Error(reply.error));
else task.resolve(reply.result);
return;
}
const blob = reply.blob ? new Blob([reply.blob], { type: reply.mime ?? 'application/octet-stream' }) : undefined;
for (const listener of listeners.get(reply.resourceId) ?? []) listener(reply.event, blob);
};
worker.onerror = () => {
for (const task of pending.values()) task.reject(new Error('加密工作线程不可用'));
pending.clear();
};
return worker;
}
interface WorkerResponse {
requestId: number;
result: unknown;
error?: never;
}
interface WorkerErrorResponse {
requestId: number;
error: string;
result?: never;
}
interface WorkerEventReply {
resourceId: string;
event: BridgeEvent;
blob?: ArrayBuffer;
mime?: string;
}
type WorkerReply = WorkerResponse | WorkerErrorResponse | WorkerEventReply;
export function callWorker<T>(operation: string, payload: unknown = {}): Promise<T> {
const requestId = nextId++;
return new Promise<T>((resolve, reject) => {
pending.set(requestId, { resolve: (value) => resolve(value as T), reject });
getWorker().postMessage({ requestId, operation, payload });
});
}
export function subscribeWorker<T extends BridgeEvent>(resourceId: string, listener: (event: T, blob?: Blob) => void): () => void {
const bucket = listeners.get(resourceId) ?? new Set();
bucket.add(listener as (event: BridgeEvent, blob?: Blob) => void);
listeners.set(resourceId, bucket);
return () => {
bucket.delete(listener as (event: BridgeEvent, blob?: Blob) => void);
if (bucket.size === 0) listeners.delete(resourceId);
};
}
export type { BridgeEvent };
@@ -68,3 +68,15 @@
font-size: 18px;
pointer-events: none;
}
.error {
position: fixed;
top: var(--bar-h);
left: var(--space-4);
right: var(--space-4);
z-index: 20;
padding: var(--space-3);
background: var(--surface);
color: var(--danger);
border: 1px solid var(--danger);
}
+3 -4
View File
@@ -33,6 +33,7 @@ export function Workspace() {
const sendFiles = useWorkspace((s) => s.sendFiles);
const sendText = useWorkspace((s) => s.sendText);
const remove = useWorkspace((s) => s.remove);
const error = useWorkspace((s) => s.error);
const download = useDownload();
// 刚创建的频道:直接展示分享面板和二维码,让对方马上能扫
@@ -61,10 +62,7 @@ export function Workspace() {
// 从首页「快速分享」带过来的内容,进入频道后发送
useEffect(() => {
const { consumePayload, clearJustCreated } = useChannels.getState();
const p = consumePayload();
if (p) sendPayload(p);
clearJustCreated();
useChannels.getState().clearJustCreated();
}, [sendPayload]);
const ids = useWorkspace(
@@ -192,6 +190,7 @@ export function Workspace() {
</div>
)}
{error ? <div className={styles.error} role="alert">{error}</div> : null}
{!desktop && panelOpen ? (
<Sheet title="设备与分享" onClose={closePanel}>
<DevicePanel qrOpen={justCreated} />
+1 -1
View File
@@ -36,7 +36,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
<span className={styles.enc}><Icon name="lock" size={12} /> 加密</span>
<span className={c.state === 'sealed' ? styles.enc : styles.raw}>{c.state === 'sealed' ? '已封存' : '草稿'}</span>
<span>{c.mine ? '我创建的' : '收到的'}</span>
<span>{formatRemaining(c.createdAt + c.ttlSeconds * 1000)}</span>
<span>{formatRemaining(c.expiresAt)}</span>
</>
) : (
<>
+4 -2
View File
@@ -3,6 +3,7 @@ import type { ChannelInfo, CreateOptions } from '@/core-bridge/types';
import { useChannels } from '@/store/channels';
import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet';
import { solveCreationCaptcha } from '@/core-bridge/captcha';
import styles from './CreateSheet.module.css';
const SEATS = [2, 3, 5, 10];
@@ -20,7 +21,7 @@ interface Props {
/** 新建频道:协议 §12 里与创建相关的设置子集 */
export function CreateSheet({ onClose, onCreated }: Props) {
const create = useChannels((s) => s.create);
const [opts, setOpts] = useState<CreateOptions>({ kind: 'channel', mode: 'enc', seats: 3, ttlSeconds: 86400, membersCanUpload: true, maxDownloads: null });
const [opts, setOpts] = useState<CreateOptions>({ mode: 'enc', seats: 3, ttlSeconds: 86400, membersCanUpload: true });
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
const set = <K extends keyof CreateOptions>(k: K, v: CreateOptions[K]) => setOpts((o) => ({ ...o, [k]: v }));
@@ -29,7 +30,8 @@ export function CreateSheet({ onClose, onCreated }: Props) {
setBusy(true);
setFailed(false);
try {
onCreated(await create(opts));
const captchaToken = await solveCreationCaptcha();
onCreated(await create(opts, captchaToken));
} catch {
setFailed(true);
setBusy(false);
+28 -6
View File
@@ -10,7 +10,7 @@
display: flex;
flex-direction: column;
gap: var(--space-4);
max-width: 560px;
max-width: 860px;
margin: 0 auto;
padding: var(--space-6) var(--space-4);
}
@@ -31,11 +31,23 @@
color: var(--text-dim);
}
.divider {
margin-top: var(--space-2);
.channels {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding-top: var(--space-4);
border-top: 1px solid var(--line);
}
.sectionHead h2 {
margin: 0;
font-size: 18px;
}
.sectionHead p {
margin: var(--space-1) 0 0;
color: var(--text-dim);
font-size: 12px;
text-align: center;
font-size: 13px;
}
.actions {
@@ -48,12 +60,22 @@
}
.note {
margin: 0;
margin: var(--space-1) 0 0;
color: var(--text-dim);
font-size: 12px;
text-align: center;
}
@media (width >= 900px) {
.main {
padding-top: 8vh;
}
.head {
padding-bottom: var(--space-4);
}
}
.error {
margin: calc(var(--space-3) * -1) 0 0;
color: var(--danger);
+40 -36
View File
@@ -1,11 +1,11 @@
import { lazy, Suspense, useCallback, useState, type DragEvent } from 'react';
import { lazy, Suspense, useCallback, useState } from 'react';
import { channelPath, navigate, sharePath } from '@/app/navigation';
import type { ChannelInfo, Share } from '@/core-bridge/types';
import { ComposeSheet } from '@/features/channel/ComposeSheet';
import type { ClipboardPayload } from '@/lib/clipboard';
import { useGlobalPaste } from '@/lib/useGlobalPaste';
import { useChannels } from '@/store/channels';
import { useShares } from '@/store/shares';
import { solveCreationCaptcha } from '@/core-bridge/captcha';
import { Button } from '@/ui/Button';
import { ChannelList } from './ChannelList';
import { CreateSheet } from './CreateSheet';
@@ -21,67 +21,74 @@ export function Home() {
const loadShareDemo = useShares((s) => s.loadDemo);
const [createOpen, setCreateOpen] = useState(false);
const [scanOpen, setScanOpen] = useState(false);
const [composeOpen, setComposeOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [dragging, setDragging] = useState(false);
const [failed, setFailed] = useState(false);
const [shareText, setShareText] = useState('');
const [shareFiles, setShareFiles] = useState<File[]>([]);
const open = (c: ChannelInfo | Share) => navigate('state' in c ? sharePath(c.id) : channelPath(c.id));
const open = useCallback((c: ChannelInfo | Share) => navigate('state' in c ? sharePath(c.id) : channelPath(c.id)), []);
const share = useCallback(
async (p: ClipboardPayload) => {
if (busy || (p.files.length === 0 && !p.text)) return;
if (busy || (p.files.length === 0 && !p.text.trim())) return false;
setBusy(true);
setFailed(false);
try {
open(await createDraft(p));
const captchaToken = await solveCreationCaptcha();
open(await createDraft(p, captchaToken));
setShareFiles([]);
setShareText('');
return true;
} catch {
setFailed(true);
return false;
} finally {
setBusy(false);
}
},
[busy, createDraft],
[busy, createDraft, open],
);
useGlobalPaste(share);
function onDragOver(e: DragEvent) {
if (!e.dataTransfer.types.includes('Files')) return;
e.preventDefault();
setDragging(true);
}
function onDrop(e: DragEvent) {
e.preventDefault();
setDragging(false);
void share({ files: [...e.dataTransfer.files], text: '' });
}
const onClipboard = useCallback((p: ClipboardPayload) => {
if (p.files.length > 0) setShareFiles((files) => [...files, ...p.files]);
if (p.text) setShareText((text) => text ? `${text}\n${p.text}` : p.text);
}, []);
useGlobalPaste(onClipboard);
return (
<div className={styles.page} onDragOver={onDragOver} onDragLeave={() => setDragging(false)} onDrop={onDrop}>
<div className={styles.page}>
<main className={styles.main}>
<header className={styles.head}>
<h1 className={styles.logo}>WebDrop</h1>
<p className={styles.sub}>在自己的设备之间快速传文件和文本,也可以分享给别人。</p>
</header>
<QuickShare busy={busy} dragging={dragging} onShare={share} onCompose={() => setComposeOpen(true)} />
<QuickShare
busy={busy}
text={shareText}
files={shareFiles}
onTextChange={setShareText}
onFilesChange={setShareFiles}
onShare={share}
/>
{failed ? <p role="alert" className={styles.error}>创建分享失败,请重试。</p> : null}
<ChannelList kind="share" title="我的分享" onOpen={open} />
<div className={styles.divider}>常驻频道:多台自己的设备之间同步</div>
<div className={styles.actions}>
<Button icon="edit" onClick={() => setCreateOpen(true)}>新建频道</Button>
<Button icon="scan" onClick={() => setScanOpen(true)}>加入频道</Button>
</div>
<section className={styles.channels} aria-labelledby="channels-title">
<div className={styles.sectionHead}>
<h2 id="channels-title">设备频道</h2>
<p>多台自己的设备之间长期同步</p>
</div>
<div className={styles.actions}>
<Button icon="edit" onClick={() => setCreateOpen(true)}>新建频道</Button>
<Button icon="scan" onClick={() => setScanOpen(true)}>加入频道</Button>
</div>
<ChannelList kind="channel" title="我的频道" onOpen={open} />
<ChannelList kind="channel" title="我的频道" onOpen={open} />
<p className={styles.note}>加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。</p>
</section>
<p className={styles.note}>
加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。
</p>
{import.meta.env.DEV ? (
<Button variant="ghost" onClick={() => { loadDemo(); loadShareDemo(); }}>载入演示数据</Button>
) : null}
@@ -89,11 +96,8 @@ export function Home() {
{createOpen ? <CreateSheet onClose={() => setCreateOpen(false)} onCreated={open} /> : null}
<Suspense fallback={null}>
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onJoined={open} /> : null}
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onChannel={open} onShare={open} /> : null}
</Suspense>
{composeOpen ? (
<ComposeSheet onClose={() => setComposeOpen(false)} onSubmit={(text) => void share({ files: [], text })} />
) : null}
</div>
);
}
+136 -18
View File
@@ -2,11 +2,10 @@
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-6) var(--space-4);
border: 2px dashed var(--line-strong);
border-radius: var(--radius-lg);
padding: var(--space-4);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface);
text-align: center;
transition: border-color 0.15s;
}
@@ -15,55 +14,174 @@
background: var(--busy-soft);
}
.head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
.title {
margin: 0;
font-size: 20px;
font-size: 18px;
}
.hint {
margin: 0;
margin: var(--space-1) 0 0;
color: var(--text-dim);
font-size: 13px;
}
.tag {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
background: var(--ok-soft);
color: var(--ok);
font-size: 12px;
}
.editor {
width: 100%;
min-height: 112px;
padding: var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
font: inherit;
line-height: 1.6;
resize: vertical;
}
.editor:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.editor::placeholder {
color: var(--text-dim);
}
.files {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
border-top: 1px solid var(--line);
list-style: none;
}
.file {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 40px;
border-bottom: 1px solid var(--line);
}
.file > span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file button {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-dim);
cursor: pointer;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.buttons {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-2);
}
.btn {
.btn,
.submit {
display: inline-flex;
flex: 1 1 120px;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 48px;
padding: 0 var(--space-4);
min-height: 42px;
padding: 0 var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
cursor: pointer;
font: inherit;
font-weight: 600;
}
.btn:disabled {
.submit {
flex: 0 0 auto;
min-width: 160px;
background: var(--accent);
color: var(--on-accent);
}
.btn:disabled,
.submit:disabled,
.file button:disabled {
opacity: 0.5;
cursor: default;
}
.btn:focus-visible {
.btn:focus-visible,
.submit:focus-visible,
.file button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn:active:not(:disabled) {
.file button:hover:not(:disabled) {
background: var(--accent-soft);
color: var(--text);
}
.btn:hover:not(:disabled) {
background: var(--accent-soft);
}
@media (hover: hover) {
.btn:hover:not(:disabled) {
background: var(--accent-soft);
.submit:hover:not(:disabled) {
opacity: 0.86;
}
@media (width <= 600px) {
.head,
.footer {
align-items: stretch;
flex-direction: column;
}
.tag {
align-self: flex-start;
}
.buttons > * {
flex: 1 1 110px;
}
.submit {
width: 100%;
}
}
+77 -28
View File
@@ -1,4 +1,4 @@
import { useRef } from 'react';
import { useRef, useState, type DragEvent } from 'react';
import type { ClipboardPayload } from '@/lib/clipboard';
import { readClipboard } from '@/lib/clipboard';
import { useMediaQuery } from '@/lib/useMediaQuery';
@@ -7,54 +7,103 @@ import styles from './QuickShare.module.css';
interface Props {
busy: boolean;
dragging: boolean;
onShare: (p: ClipboardPayload) => void;
onCompose: () => void;
text: string;
files: File[];
onTextChange: (text: string) => void;
onFilesChange: (files: File[]) => void;
onShare: (payload: ClipboardPayload) => Promise<boolean>;
}
/** 快速分享:拖入、粘贴或选择内容,自动创建一个临时加密频道并给出分享链接 */
export function QuickShare({ busy, dragging, onShare, onCompose }: Props) {
/** 临时分享入口:先收集文本和文件,确认后再建立草稿。 */
export function QuickShare({ busy, text, files, onTextChange, onFilesChange, onShare }: Props) {
const touch = useMediaQuery('(pointer: coarse)');
const fileRef = useRef<HTMLInputElement>(null);
const galleryRef = useRef<HTMLInputElement>(null);
const cameraRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const canShare = text.trim().length > 0 || files.length > 0;
async function paste() {
const clip = await readClipboard();
if (!clip) return onCompose(); // 无权限时退回手动输入
onShare({ files: clip.files, text: clip.files.length > 0 ? '' : clip.text.trim() });
if (!clip) return;
if (clip.files.length) onFilesChange([...files, ...clip.files]);
if (clip.text) onTextChange(text ? `${text}\n${clip.text}` : clip.text);
}
function pick(files: FileList | null) {
if (files?.length) onShare({ files: [...files], text: '' });
function onDrop(event: DragEvent<HTMLElement>) {
event.preventDefault();
setDragging(false);
if (event.dataTransfer.files.length) onFilesChange([...files, ...event.dataTransfer.files]);
}
const buttons: { key: string; label: string; icon: IconName; run: () => void; touchOnly?: boolean }[] = [
function pick(list: FileList | null) {
if (list?.length) onFilesChange([...files, ...list]);
}
const actions: { key: string; label: string; icon: IconName; run: () => void; touchOnly?: boolean }[] = [
{ key: 'file', label: '选择文件', icon: 'file', run: () => fileRef.current?.click() },
{ key: 'paste', label: '粘贴', icon: 'paste', run: () => void paste() },
{ key: 'write', label: '写文本', icon: 'edit', run: onCompose },
{ key: 'gallery', label: '相册', icon: 'image', run: () => galleryRef.current?.click(), touchOnly: true },
{ key: 'camera', label: '拍照', icon: 'camera', run: () => cameraRef.current?.click(), touchOnly: true },
];
return (
<section className={`${styles.card} ${dragging ? styles.dragging : ''}`} aria-busy={busy}>
<input ref={fileRef} type="file" multiple hidden onChange={(e) => { pick(e.target.files); e.target.value = ''; }} />
<input ref={galleryRef} type="file" accept="image/*,video/*" multiple hidden onChange={(e) => { pick(e.target.files); e.target.value = ''; }} />
<input ref={cameraRef} type="file" accept="image/*" capture="environment" hidden onChange={(e) => { pick(e.target.files); e.target.value = ''; }} />
<section
className={`${styles.card} ${dragging ? styles.dragging : ''}`}
aria-busy={busy}
onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }}
onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false); }}
onDrop={onDrop}
>
<input ref={fileRef} type="file" multiple hidden onChange={(event) => { pick(event.target.files); event.target.value = ''; }} />
<input ref={galleryRef} type="file" accept="image/*,video/*" multiple hidden onChange={(event) => { pick(event.target.files); event.target.value = ''; }} />
<input ref={cameraRef} type="file" accept="image/*" capture="environment" hidden onChange={(event) => { pick(event.target.files); event.target.value = ''; }} />
<h2 className={styles.title}>{busy ? '正在创建分享…' : '快速分享'}</h2>
<p className={styles.hint}>
{touch ? '选择文件或文本' : '把文件拖到这里,或直接 Ctrl+V 粘贴文本'},做成一个分享列表发给别人下载。完成后内容只读,连你自己也不能再改。
</p>
<div className={styles.buttons}>
{buttons.filter((b) => !b.touchOnly || touch).map((b) => (
<button key={b.key} type="button" className={styles.btn} disabled={busy} onClick={b.run}>
<Icon name={b.icon} size={20} />
<span>{b.label}</span>
</button>
))}
</div>
<header className={styles.head}>
<div>
<h2 className={styles.title}>快速分享</h2>
<p className={styles.hint}>临时文本直接写在这里,也可以拖入文件。内容封存后只读。</p>
</div>
<span className={styles.tag}><Icon name="lock" size={13} /> 端到端加密</span>
</header>
<textarea
className={styles.editor}
aria-label="临时分享文本"
placeholder="输入或粘贴要分享的文本…"
value={text}
onChange={(event) => onTextChange(event.target.value)}
rows={4}
disabled={busy}
/>
{files.length > 0 ? (
<ul className={styles.files} aria-label="待分享文件">
{files.map((file, index) => (
<li className={styles.file} key={`${file.name}:${file.size}:${index}`}>
<Icon name="file" size={16} />
<span>{file.name}</span>
<button type="button" aria-label={`移除 ${file.name}`} disabled={busy} onClick={() => onFilesChange(files.filter((_, i) => i !== index))}>
<Icon name="close" size={15} />
</button>
</li>
))}
</ul>
) : null}
<footer className={styles.footer}>
<div className={styles.buttons}>
{actions.filter((action) => !action.touchOnly || touch).map((action) => (
<button key={action.key} type="button" className={styles.btn} disabled={busy} onClick={action.run}>
<Icon name={action.icon} size={18} />
<span>{action.label}</span>
</button>
))}
</div>
<button type="button" className={styles.submit} disabled={busy || !canShare} onClick={() => void onShare({ files, text })}>
{busy ? '正在创建分享…' : '创建临时分享'}
</button>
</footer>
</section>
);
}
+1 -1
View File
@@ -34,7 +34,7 @@ function InlineText({ item, onOpen }: { item: TextItem; onOpen: () => void }) {
* 所以先放占位,点了才加载,避免滚动列表就把次数用光。
*/
function InlineVideo({ item }: { item: FileItem }) {
const free = item.remaining === null;
const free = item.remaining === null && !item.gated;
const [play, setPlay] = useState(free);
const blob = useBlobUrl(item.id, play);
+1 -1
View File
@@ -14,7 +14,7 @@ function Thumb({ id }: { id: string }) {
* 有下载上限的文件(GATED)拉取一次就消耗一次下载,所以不自动拉取缩略图。
*/
export function Tile({ item, size = 44 }: { item: Item; size?: number }) {
const thumb = item.kind === 'file' && item.preview === 'image' && item.state.kind === 'complete' && item.remaining === null;
const thumb = item.kind === 'file' && !item.gated && item.preview === 'image' && item.state.kind === 'complete' && item.remaining === null;
return (
<span className={styles.tile} style={{ width: size, height: size }}>
{thumb ? <Thumb id={item.id} /> : <Icon name={iconOf(item)} size={Math.round(size / 2.2)} />}
+1 -1
View File
@@ -27,7 +27,7 @@ export const channelSource: ItemSource = {
useItem: (id) => useWorkspace((s) => s.byId[id]),
useDeviceNamer: () => {
const devices = useWorkspace((s) => s.devices);
return (deviceId) => devices.find((d) => d.id === deviceId)?.name;
return (deviceId) => devices.find((d) => d.id === deviceId)?.name ?? deviceId;
},
getBlob: (id, versionId) => useWorkspace.getState().getBlob(id, versionId),
listArchive: (id) => useWorkspace.getState().listArchive(id),
@@ -0,0 +1,94 @@
.empty {
display: grid;
min-height: 260px;
padding: var(--space-6);
place-items: center;
color: var(--text-dim);
text-align: center;
}
.preview {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-4);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--line);
}
.head h2 {
margin: 0;
overflow-wrap: anywhere;
font-size: 18px;
}
.head p {
margin: var(--space-1) 0 0;
color: var(--text-dim);
font-size: 12px;
}
.editor {
width: 100%;
min-height: 280px;
padding: var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
font: inherit;
line-height: 1.65;
resize: vertical;
}
.editor:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.error {
margin: 0;
color: var(--danger);
font-size: 13px;
}
.fileActions {
display: flex;
align-items: center;
gap: var(--space-3);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--line);
}
.fileActions > span {
color: var(--text-dim);
font-size: 12px;
}
.hidden {
display: none;
}
@media (width <= 600px) {
.head {
align-items: stretch;
flex-direction: column;
}
.fileActions {
align-items: flex-start;
flex-direction: column;
}
.editor {
min-height: 180px;
}
}
+92
View File
@@ -0,0 +1,92 @@
import { useRef, useState } from 'react';
import type { FileItem, Item, TextItem } from '@/core-bridge/types';
import ItemDetail from '@/features/item/ItemDetail';
import { formatSize, formatTime } from '@/lib/format';
import { useShareSession } from '@/store/shareSession';
import { Button } from '@/ui/Button';
import styles from './DraftPreview.module.css';
interface Props {
item?: Item;
onSelect: (id: string) => void;
}
export function DraftPreview({ item, onSelect }: Props) {
if (!item) return <div className={styles.empty}>选择一项内容进行预览或修改。</div>;
return item.kind === 'text'
? <TextDraft item={item} />
: <FileDraft item={item} onSelect={onSelect} />;
}
function TextDraft({ item }: { item: TextItem }) {
const [text, setText] = useState(item.text);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const editText = useShareSession((s) => s.editText);
const dirty = text !== item.text;
async function save() {
setSaving(true);
setError('');
try {
await editText(item.id, text);
} catch {
setError('保存失败,请重试。');
} finally {
setSaving(false);
}
}
return (
<section className={styles.preview} aria-label="文本编辑">
<header className={styles.head}>
<div>
<h2>文本</h2>
<p>{formatTime(item.ts)} · {formatSize(new Blob([text]).size)}</p>
</div>
<Button variant="primary" disabled={!dirty || saving} onClick={() => void save()}>{saving ? '保存中…' : '保存修改'}</Button>
</header>
<textarea className={styles.editor} aria-label="修改分享文本" value={text} onChange={(event) => setText(event.target.value)} />
{error ? <p className={styles.error} role="alert">{error}</p> : null}
</section>
);
}
function FileDraft({ item, onSelect }: { item: FileItem; onSelect: (id: string) => void }) {
const input = useRef<HTMLInputElement>(null);
const [replacing, setReplacing] = useState(false);
const addFiles = useShareSession((s) => s.addFiles);
const removeItem = useShareSession((s) => s.removeItem);
async function replace(file: File | undefined) {
if (!file || replacing) return;
setReplacing(true);
const oldIds = new Set(useShareSession.getState().order);
try {
await addFiles([file]);
const state = useShareSession.getState();
const newId = state.order.find((id) => !oldIds.has(id));
const next = newId ? state.byId[newId] : undefined;
if (newId && next?.state.kind === 'complete') {
await removeItem(item.id);
onSelect(newId);
}
} finally {
setReplacing(false);
if (input.current) input.current.value = '';
}
}
return (
<section className={styles.preview} aria-label="文件预览">
<input ref={input} className={styles.hidden} type="file" onChange={(event) => void replace(event.target.files?.[0])} />
<div className={styles.fileActions}>
<Button disabled={replacing || item.state.kind === 'receiving'} onClick={() => input.current?.click()}>
{replacing ? '正在替换…' : '替换文件'}
</Button>
<span>替换成功后,原文件会从草稿中移除。</span>
</div>
<ItemDetail id={item.id} showTitle readOnly onDelete={() => {}} />
</section>
);
}
+28 -16
View File
@@ -1,10 +1,35 @@
.row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4);
gap: var(--space-1);
min-height: 68px;
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--line);
animation: row-in 0.21s ease-out;
}
.selected {
background: var(--accent-soft);
}
.select {
display: flex;
flex: 1;
align-items: center;
gap: var(--space-3);
min-width: 0;
min-height: 56px;
padding: var(--space-1) var(--space-2);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
cursor: pointer;
text-align: left;
}
.select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.text {
@@ -41,16 +66,3 @@
background: var(--progress-gradient);
transition: width 0.25s linear;
}
@keyframes row-in {
from {
opacity: 0;
translate: 0 -6px;
}
}
@media (prefers-reduced-motion: reduce) {
.row {
animation: none;
}
}
+14 -12
View File
@@ -6,7 +6,7 @@ import { Tile } from '@/features/item/Tile';
import styles from './DraftRow.module.css';
/** 草稿里的一行:可以移除。只在草稿阶段存在 */
export function DraftRow({ id }: { id: string }) {
export function DraftRow({ id, selected, onSelect }: { id: string; selected: boolean; onSelect: () => void }) {
const item = useShareSession((s) => s.byId[id]);
const removeItem = useShareSession((s) => s.removeItem);
if (!item) return null;
@@ -15,17 +15,19 @@ export function DraftRow({ id }: { id: string }) {
const size = item.kind === 'file' ? item.size : new Blob([item.text]).size;
return (
<li className={styles.row}>
<Tile item={item} />
<span className={styles.text}>
<span className={styles.title}>{titleOf(item)}</span>
<span className={styles.sub}>{formatSize(size)}{receiving !== null ? ` · 传输中 ${Math.round(receiving * 100)}%` : ''}</span>
{receiving !== null ? (
<span className={styles.bar} role="progressbar" aria-valuenow={Math.round(receiving * 100)}>
<span className={styles.fill} style={{ width: `${Math.round(receiving * 100)}%` }} />
</span>
) : null}
</span>
<li className={`${styles.row} ${selected ? styles.selected : ''}`}>
<button type="button" className={styles.select} aria-pressed={selected} onClick={onSelect}>
<Tile item={item} />
<span className={styles.text}>
<span className={styles.title}>{titleOf(item)}</span>
<span className={styles.sub}>{formatSize(size)}{receiving !== null ? ` · 传输中 ${Math.round(receiving * 100)}%` : ''}</span>
{receiving !== null ? (
<span className={styles.bar} role="progressbar" aria-valuenow={Math.round(receiving * 100)}>
<span className={styles.fill} style={{ width: `${Math.round(receiving * 100)}%` }} />
</span>
) : null}
</span>
</button>
<Button variant="ghost" icon="close" label="移除" onClick={() => void removeItem(id)} />
</li>
);
@@ -22,6 +22,7 @@
@media (prefers-reduced-transparency: reduce) {
.bar {
background: var(--bg);
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
+103 -19
View File
@@ -12,7 +12,8 @@
display: flex;
flex: 1;
flex-direction: column;
width: min(100%, 640px);
width: min(100%, 1120px);
min-height: 0;
margin: 0 auto;
}
@@ -31,8 +32,52 @@
.empty {
margin: 0;
padding: var(--space-6) var(--space-4);
padding: var(--space-4);
color: var(--text-dim);
font-size: 13px;
}
.content {
display: grid;
min-height: 320px;
flex: 1;
grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.listPane {
min-width: 0;
overflow-y: auto;
border-right: 1px solid var(--line);
}
.listTitle {
display: flex;
align-items: baseline;
justify-content: space-between;
margin: 0;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--line);
font-size: 13px;
}
.listTitle span {
color: var(--text-dim);
font-family: var(--font-mono);
font-weight: 400;
}
.previewPane {
min-width: 0;
overflow: auto;
}
.error {
margin: 0;
padding: var(--space-3) var(--space-4);
color: var(--danger);
font-size: 13px;
}
.list {
@@ -80,8 +125,32 @@
backdrop-filter: var(--blur-bar);
}
.footer > * {
width: min(100%, 640px);
.completion {
display: grid;
width: min(100%, 1120px);
grid-template-columns: minmax(0, 1fr) minmax(220px, 280px);
align-items: center;
gap: var(--space-4);
}
.acknowledgement {
display: flex;
align-items: flex-start;
gap: var(--space-2);
color: var(--text-dim);
font-size: 13px;
line-height: 1.5;
}
.acknowledgement input {
flex: 0 0 auto;
width: 18px;
height: 18px;
margin: 1px 0 0;
accent-color: var(--accent);
}
.completion > button {
min-height: 48px;
}
@@ -94,27 +163,42 @@
@media (prefers-reduced-transparency: reduce) {
.footer {
background: var(--bg);
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
}
.confirm {
display: flex;
flex-direction: column;
gap: var(--space-3);
@media (width <= 899px) {
.main {
flex: none;
}
.content {
display: flex;
min-height: 0;
flex-direction: column;
flex: none;
}
.listPane {
max-height: 35vh;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.previewPane {
min-height: 0;
overflow: visible;
}
}
.confirm p {
margin: 0;
}
@media (width <= 600px) {
.completion {
grid-template-columns: minmax(0, 1fr);
}
.dim {
color: var(--text-dim);
font-size: 13px;
}
.confirmActions {
display: flex;
gap: var(--space-2);
.settings {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
+40 -27
View File
@@ -7,8 +7,8 @@ import { useGlobalPaste } from '@/lib/useGlobalPaste';
import { useShares } from '@/store/shares';
import { useShareSession } from '@/store/shareSession';
import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet';
import { DraftRow } from './DraftRow';
import { DraftPreview } from './DraftPreview';
import { ShareBar } from './ShareBar';
import styles from './ShareDraft.module.css';
@@ -29,20 +29,25 @@ const DOWNLOADS = [
export function ShareDraft() {
const share = useShareSession((s) => s.share);
const ids = useShareSession((s) => s.order);
const error = useShareSession((s) => s.error);
const busy = useShareSession((s) => s.order.some((id) => s.byId[id]?.state.kind === 'receiving'));
const addFiles = useShareSession((s) => s.addFiles);
const addText = useShareSession((s) => s.addText);
const updateSettings = useShares((s) => s.updateSettings);
const seal = useShares((s) => s.seal);
const [composeOpen, setComposeOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [sealing, setSealing] = useState(false);
const [acknowledged, setAcknowledged] = useState(false);
const [dragging, setDragging] = useState(false);
const [selectedId, setSelectedId] = useState<string>();
const [sealError, setSealError] = useState('');
const activeId = selectedId && ids.includes(selectedId) ? selectedId : ids[0];
const selectedItem = useShareSession((s) => activeId ? s.byId[activeId] : undefined);
const add = useCallback(
(p: ClipboardPayload) => {
if (p.files.length > 0) void addFiles(p.files);
else if (p.text) void addText(p.text);
if (p.text.trim()) void addText(p.text);
},
[addFiles, addText],
);
@@ -61,11 +66,13 @@ export function ShareDraft() {
async function finish() {
if (!share) return;
setSealing(true);
setSealError('');
try {
await seal(share.id); // 成功后分享状态变为 sealed,页面自动切换到只读页
} catch {
setSealError('完成分享失败,草稿仍可修改,请重试。');
} finally {
setSealing(false);
setConfirmOpen(false);
}
}
@@ -92,13 +99,23 @@ export function ShareDraft() {
onCompose={() => setComposeOpen(true)}
/>
{ids.length === 0 ? (
<p className={styles.empty}>还没有内容。拖入文件、粘贴文本,或点上面的按钮。</p>
) : (
<ul className={styles.list}>
{ids.map((id) => <DraftRow key={id} id={id} />)}
</ul>
)}
<div className={styles.content}>
<aside className={styles.listPane} aria-label="草稿内容列表">
<h2 className={styles.listTitle}>内容清单 <span>{ids.length}</span></h2>
{ids.length === 0 ? (
<p className={styles.empty}>拖入文件、粘贴文本,或点上方按钮添加。</p>
) : (
<ul className={styles.list}>
{ids.map((id) => (
<DraftRow key={id} id={id} selected={id === activeId} onSelect={() => setSelectedId(id)} />
))}
</ul>
)}
</aside>
<section className={styles.previewPane} aria-label="选中内容">
<DraftPreview item={selectedItem} onSelect={setSelectedId} />
</section>
</div>
<section className={styles.settings} aria-label="分享设置">
<label>
@@ -109,7 +126,7 @@ export function ShareDraft() {
</label>
<label>
<span>最多访客</span>
<select value={share.seats} onChange={(e) => patch({ seats: Number(e.target.value) })}>
<select value={share.maxVisitors} onChange={(e) => patch({ maxVisitors: Number(e.target.value) })}>
{SEATS.map((n) => <option key={n} value={n}>{n} 人</option>)}
</select>
</label>
@@ -120,27 +137,23 @@ export function ShareDraft() {
</select>
</label>
</section>
{error ? <p className={styles.error} role="alert">{error}</p> : null}
{sealError ? <p className={styles.error} role="alert">{sealError}</p> : null}
</main>
<footer className={styles.footer}>
<Button variant="primary" disabled={ids.length === 0 || busy} onClick={() => setConfirmOpen(true)}>
{busy ? '传输中…' : '完成分享'}
</Button>
<div className={styles.completion}>
<label className={styles.acknowledgement}>
<input type="checkbox" checked={acknowledged} disabled={sealing} onChange={(event) => setAcknowledged(event.target.checked)} />
<span>完成后会生成分享链接和二维码。此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期。</span>
</label>
<Button variant="primary" disabled={ids.length === 0 || busy || sealing || !acknowledged} onClick={() => void finish()}>
{busy ? '传输中…' : sealing ? '正在完成…' : '完成分享'}
</Button>
</div>
</footer>
{composeOpen ? <ComposeSheet onClose={() => setComposeOpen(false)} onSubmit={(t) => void addText(t)} /> : null}
{confirmOpen ? (
<Sheet title="完成分享" onClose={() => setConfirmOpen(false)}>
<div className={styles.confirm}>
<p>完成后会生成分享链接和二维码。<strong>此后任何人(包括你)都不能再添加、修改或删除内容</strong>,只能等它过期。</p>
<p className={styles.dim}>发错了也撤不回,请先确认列表。</p>
<div className={styles.confirmActions}>
<Button variant="primary" disabled={sealing} onClick={() => void finish()}>{sealing ? '正在完成…' : '确认完成'}</Button>
<Button disabled={sealing} onClick={() => setConfirmOpen(false)}>返回修改</Button>
</div>
</div>
</Sheet>
) : null}
</div>
);
}
@@ -69,6 +69,13 @@
font-size: 13px;
}
.error {
margin: 0;
padding: var(--space-3) var(--space-4);
color: var(--danger);
font-size: 13px;
}
.head {
display: flex;
align-items: center;
+5 -1
View File
@@ -18,13 +18,15 @@ const noop = () => {};
export function ShareView() {
const share = useShareSession((s) => s.share);
const ids = useShareSession((s) => s.order);
const integrityError = useShareSession((s) => s.integrityError);
const error = useShareSession((s) => s.error);
const download = useDownload();
const [sheetId, setSheetId] = useState<string>();
const [showQr, setShowQr] = useState(true);
const closeSheet = useCallback(() => setSheetId(undefined), []);
if (!share) return null;
const remaining = formatRemaining(share.createdAt + share.ttlSeconds * 1000);
const remaining = formatRemaining(share.expiresAt);
async function downloadAll() {
const { byId } = useShareSession.getState();
@@ -42,6 +44,8 @@ export function ShareView() {
badge={<span className={styles.badge}><Icon name="lock" size={12} /> 已封存 · 只读</span>}
/>
<main className={styles.main}>
{integrityError ? <p className={styles.error} role="alert">{integrityError}</p> : null}
{error ? <p className={styles.error} role="alert">{error}</p> : null}
{share.mine ? (
<section className={styles.share} aria-label="分享给别人">
<h2 className={styles.h}>分享给别人</h2>
+3
View File
@@ -29,6 +29,9 @@ describe('parseChannelLink', () => {
['路径不对', `https://x.example/other/${ID}`, 'bad_url'],
['id 长度不对', 'https://x.example/c/abc', 'bad_id'],
['密钥长度不对', `https://x.example/c/${ID}#k=short`, 'bad_key'],
['密钥误放进查询串', `https://x.example/c/${ID}?k=${KEY}`, 'bad_key'],
['管理密钥误放进查询串', `https://x.example/c/${ID}?a=${KEY}`, 'bad_key'],
['写密钥误放进查询串', `https://x.example/c/${ID}?w=${KEY}`, 'bad_key'],
])('%s → %s', (_n, input, error) => {
expect(parseChannelLink(input)).toEqual({ ok: false, error });
});
+1
View File
@@ -23,6 +23,7 @@ export function parseChannelLink(input: string): LinkResult {
} catch {
return { ok: false, error: 'bad_url' };
}
if (['k', 'a', 'w'].some((name) => url.searchParams.has(name))) return { ok: false, error: 'bad_key' };
const m = /^\/c\/([^/]+)\/?$/.exec(url.pathname);
if (!m || !m[1] || !ID_RE.test(m[1])) return { ok: false, error: m ? 'bad_id' : 'bad_url' };
+1
View File
@@ -19,6 +19,7 @@ describe('parseShareLink', () => {
['id 长度不对', `https://x.example/s/abc#k=${KEY}`, 'bad_id'],
['缺少密钥', `https://x.example/s/${ID}`, 'bad_key'],
['密钥长度不对', `https://x.example/s/${ID}#k=short`, 'bad_key'],
['密钥误放进查询串', `https://x.example/s/${ID}?k=${KEY}`, 'bad_key'],
])('%s → %s', (_n, input, error) => {
expect(parseShareLink(input)).toEqual({ ok: false, error });
});
+1
View File
@@ -19,6 +19,7 @@ export function parseShareLink(input: string): ShareLinkResult {
} catch {
return { ok: false, error: 'bad_url' };
}
if (['k', 'a', 'w'].some((name) => url.searchParams.has(name))) return { ok: false, error: 'bad_key' };
const m = /^\/s\/([^/]+)\/?$/.exec(url.pathname);
if (!m?.[1]) return { ok: false, error: 'bad_url' };
if (!ID_RE.test(m[1])) return { ok: false, error: 'bad_id' };
+3
View File
@@ -21,3 +21,6 @@ createRoot(document.getElementById('root')!).render(
<App />
</StrictMode>,
);
void useChannels.getState().hydrate();
void useShares.getState().hydrate();
+16 -5
View File
@@ -6,28 +6,32 @@ import type { ParsedChannelLink } from '@/lib/channelLink';
interface ChannelsState {
/** 我的频道(原型:只在内存里,刷新即清空;真实实现存 IndexedDB,architecture §6) */
list: ChannelInfo[];
hydrated: boolean;
/** 从链接进来、还没确认加入的频道;含密钥,只放内存 */
pendingLink: ParsedChannelLink | null;
/** 刚创建的频道:进入时直接展示分享面板与二维码 */
justCreatedId: string | null;
create: (opts: CreateOptions) => Promise<ChannelInfo>;
create: (opts: CreateOptions, captchaToken?: string) => Promise<ChannelInfo>;
join: (link: ParsedChannelLink) => Promise<ChannelInfo>;
forget: (id: string) => void;
hydrate: () => Promise<void>;
forget: (id: string) => Promise<void>;
loadDemo: () => void;
setPendingLink: (link: ParsedChannelLink | null) => void;
clearJustCreated: () => void;
}
const upsert = (list: ChannelInfo[], c: ChannelInfo) => [c, ...list.filter((x) => x.id !== c.id)];
let hydration: Promise<void> | undefined;
export const useChannels = create<ChannelsState>()((set) => ({
list: [],
hydrated: false,
pendingLink: null,
justCreatedId: null,
create: async (opts) => {
const c = await api.createChannel(opts);
create: async (opts, captchaToken) => {
const c = await api.createChannel(opts, captchaToken);
set((s) => ({ list: upsert(s.list, c), justCreatedId: c.id }));
return c;
},
@@ -36,7 +40,14 @@ export const useChannels = create<ChannelsState>()((set) => ({
set((s) => ({ list: upsert(s.list, c), pendingLink: null }));
return c;
},
forget: (id) => set((s) => ({ list: s.list.filter((c) => c.id !== id) })),
hydrate: () => {
hydration ??= api.listSaved().then((list) => set({ list, hydrated: true }), () => set({ hydrated: true }));
return hydration;
},
forget: async (id) => {
await api.forget(id);
set((s) => ({ list: s.list.filter((c) => c.id !== id) }));
},
loadDemo: () => {
const demo = api.loadDemo?.() ?? [];
set((s) => ({ list: demo.reduce(upsert, s.list) }));
+6
View File
@@ -16,6 +16,7 @@ export interface WorkspaceData {
/** 新的在前 */
order: string[];
snapshot: Snapshot;
error: string | null;
}
export const initialData: WorkspaceData = {
@@ -26,6 +27,7 @@ export const initialData: WorkspaceData = {
byId: {},
order: [],
snapshot: { text: '', updatedBy: '', updatedAt: 0 },
error: null,
};
function insertByTime(order: string[], byId: Record<string, Item>, item: Item): string[] {
@@ -37,6 +39,10 @@ function insertByTime(order: string[], byId: Record<string, Item>, item: Item):
export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
switch (e.t) {
case 'reset':
return { ...initialData, connection: s.connection };
case 'error':
return { ...s, error: e.message };
case 'ready':
return { ...s, ready: true };
case 'connection':
+1 -1
View File
@@ -3,7 +3,7 @@ import type { Share, TextItem } from '@/core-bridge/types';
import { initialShareData, reduceShare } from './shareReduce';
const text = (id: string, ts: number): TextItem => ({ id, kind: 'text', ts, deviceId: 'x', state: { kind: 'complete' }, text: id });
const share: Share = { id: 's', state: 'draft', mine: true, link: 'l', createdAt: 0, ttlSeconds: 1, seats: 2, maxDownloads: null };
const share: Share = { id: 's', state: 'draft', mine: true, link: 'l', createdAt: 0, expiresAt: 86400000, ttlSeconds: 1, maxVisitors: 2, maxDownloads: null };
describe('reduceShare', () => {
it('收到分享信息与就绪', () => {
+7 -1
View File
@@ -6,9 +6,11 @@ export interface ShareData {
byId: Record<string, Item>;
/** 新的在前 */
order: string[];
integrityError: string | null;
error: string | null;
}
export const initialShareData: ShareData = { ready: false, share: null, byId: {}, order: [] };
export const initialShareData: ShareData = { ready: false, share: null, byId: {}, order: [], integrityError: null, error: null };
function insertByTime(order: string[], byId: Record<string, Item>, item: Item): string[] {
const at = order.findIndex((id) => (byId[id]?.ts ?? 0) < item.ts);
@@ -34,6 +36,10 @@ export function reduceShare(s: ShareData, e: ShareEvent): ShareData {
delete byId[e.id];
return { ...s, byId, order: s.order.filter((id) => id !== e.id) };
}
case 'integrity':
return { ...s, byId: {}, order: [], integrityError: e.message };
case 'error':
return { ...s, error: e.message };
default: {
const exhaustive: never = e;
return exhaustive;
+2
View File
@@ -6,6 +6,7 @@ interface Actions {
/** 打开(订阅)一个分享:先清空上一个的数据,返回取消订阅函数 */
open: (shareId: string) => () => void;
addText: (text: string) => Promise<void>;
editText: (id: string, text: string) => Promise<void>;
addFiles: (files: File[]) => Promise<void>;
removeItem: (id: string) => Promise<void>;
getBlob: (id: string) => Promise<Blob>;
@@ -20,6 +21,7 @@ export const useShareSession = create<ShareData & Actions>()(() => ({
return shareApi.connect(shareId, (e) => useShareSession.setState((s) => reduceShare(s, e)));
},
addText: (t) => shareApi.addText(t),
editText: (id, text) => shareApi.editText(id, text),
addFiles: (f) => shareApi.addFiles(f),
removeItem: (id) => shareApi.removeItem(id),
getBlob: (id) => shareApi.getBlob(id),
+17 -6
View File
@@ -5,41 +5,49 @@ import type { ClipboardPayload } from '@/lib/clipboard';
import type { ParsedShareLink } from '@/lib/shareLink';
/** 便捷分享的默认设置:最多 10 位访客、24 小时、每个文件最多下载 10 次 */
export const SHARE_DEFAULTS: ShareSettings = { ttlSeconds: 86400, seats: 10, maxDownloads: 10 };
export const SHARE_DEFAULTS: ShareSettings = { ttlSeconds: 86400, maxVisitors: 10, maxDownloads: 10 };
interface SharesState {
/** 我的分享:自己创建的(草稿、已封存)和收到的。原型只在内存里 */
list: Share[];
hydrated: boolean;
/** 从链接进来、还没加入的分享;含密钥,只放内存 */
pendingLink: ParsedShareLink | null;
/** 便捷分享:新建草稿时带来的内容,进入草稿页后放进去 */
pendingPayload: ClipboardPayload | null;
/** 新建草稿(可带上要放进去的内容) */
createDraft: (payload?: ClipboardPayload) => Promise<Share>;
createDraft: (payload?: ClipboardPayload, captchaToken?: string) => Promise<Share>;
hydrate: () => Promise<void>;
updateSettings: (id: string, patch: Partial<ShareSettings>) => Promise<Share>;
/** 完成分享:此后无人可改 */
seal: (id: string) => Promise<Share>;
/** 访客加入:直接进入,不预览 */
join: (link: ParsedShareLink) => Promise<Share>;
forget: (id: string) => void;
forget: (id: string) => Promise<void>;
loadDemo: () => void;
setPendingLink: (link: ParsedShareLink | null) => void;
consumePayload: () => ClipboardPayload | null;
}
const upsert = (list: Share[], s: Share) => [s, ...list.filter((x) => x.id !== s.id)];
let hydration: Promise<void> | undefined;
export const useShares = create<SharesState>()((set, get) => ({
list: [],
hydrated: false,
pendingLink: null,
pendingPayload: null,
createDraft: async (payload) => {
const s = await shareApi.createDraft(SHARE_DEFAULTS);
createDraft: async (payload, captchaToken) => {
const s = await shareApi.createDraft(SHARE_DEFAULTS, captchaToken);
set((st) => ({ list: upsert(st.list, s), pendingPayload: payload ?? null }));
return s;
},
hydrate: () => {
hydration ??= shareApi.listSaved().then((list) => set({ list, hydrated: true }), () => set({ hydrated: true }));
return hydration;
},
updateSettings: async (id, patch) => {
const s = await shareApi.updateSettings(id, patch);
set((st) => ({ list: upsert(st.list, s) }));
@@ -55,7 +63,10 @@ export const useShares = create<SharesState>()((set, get) => ({
set((st) => ({ list: upsert(st.list, s), pendingLink: null }));
return s;
},
forget: (id) => set((st) => ({ list: st.list.filter((x) => x.id !== id) })),
forget: async (id) => {
await shareApi.forget(id);
set((st) => ({ list: st.list.filter((x) => x.id !== id) }));
},
loadDemo: () => {
const demo = shareApi.loadDemo?.() ?? [];
set((st) => ({ list: demo.reduce(upsert, st.list) }));
+1 -1
View File
@@ -15,7 +15,7 @@
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"],
"paths": { "@/*": ["./src/*"] }
"paths": { "@/*": ["./src/*"], "@webdrop/core": ["../packages/core/src/index.ts"] }
},
"include": ["src"]
}
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more