From 7406e2fe377d79dd85f870111a0efca2ae7c6591 Mon Sep 17 00:00:00 2001 From: JustMe Date: Fri, 2 Oct 2026 10:49:45 +0800 Subject: [PATCH] feat: integrate client and server sharing workflows --- .dockerignore | 16 ++ .env.example | 18 ++ .gitignore | 3 + .woodpecker/ci.yml | 38 +++++ .woodpecker/release-backend.yml | 29 ++++ README.md | 1 + compose.yaml | 32 ++++ contracts/api-contract.json | 3 + docs/api.md | 8 +- docs/deployment.md | 45 +++++ docs/frontend.md | 5 +- docs/openapi.yaml | 19 ++- docs/protocol.md | 2 + docs/ui-design.md | 49 +++--- packages/core/src/constants.ts | 2 +- packages/core/src/item.ts | 2 +- scripts/check-api-contract.mjs | 39 +++++ server/Dockerfile | 28 ++++ server/src/WebDrop.Api/ApiContract.cs | 7 + server/src/WebDrop.Api/ApiJson.cs | 1 + server/src/WebDrop.Core/Abstractions.cs | 2 + server/src/WebDrop.Core/ChannelService.cs | 21 ++- server/src/WebDrop.Core/ShareService.cs | 12 +- .../src/WebDrop.Storage/SqliteChannelStore.cs | 25 ++- server/tests/WebDrop.Api.Tests/RestTests.cs | 29 ++++ server/tests/WebDrop.Api.Tests/ShareTests.cs | 31 ++++ .../WebDrop.Core.Tests/ChannelServiceTests.cs | 71 +++++++- web/package-lock.json | 25 +++ web/package.json | 2 + web/src/app/App.tsx | 12 +- web/src/app/entry.test.ts | 29 ++++ web/src/app/entry.ts | 10 +- web/src/core-bridge/api.ts | 5 +- web/src/core-bridge/captcha.ts | 25 +++ web/src/core-bridge/contract.test.ts | 16 ++ web/src/core-bridge/contract.ts | 9 + web/src/core-bridge/database.ts | 143 ++++++++++++++++ web/src/core-bridge/errors.ts | 20 +++ web/src/core-bridge/instance.ts | 10 +- web/src/core-bridge/mock.ts | 6 +- web/src/core-bridge/mockSeed.ts | 1 + web/src/core-bridge/mockShare.ts | 16 +- web/src/core-bridge/real.ts | 85 ++++++++++ web/src/core-bridge/shareApi.ts | 6 +- web/src/core-bridge/types.ts | 14 +- web/src/core-bridge/worker.ts | 74 +++++++++ web/src/core-bridge/worker/channelEvents.ts | 131 +++++++++++++++ web/src/core-bridge/worker/channelItems.ts | 62 +++++++ .../core-bridge/worker/channelResources.ts | 95 +++++++++++ web/src/core-bridge/worker/channelSend.ts | 71 ++++++++ .../core-bridge/worker/channelTransport.ts | 80 +++++++++ web/src/core-bridge/worker/http.test.ts | 32 ++++ web/src/core-bridge/worker/http.ts | 58 +++++++ web/src/core-bridge/worker/links.ts | 17 ++ web/src/core-bridge/worker/port.ts | 14 ++ web/src/core-bridge/worker/presentation.ts | 30 ++++ web/src/core-bridge/worker/serial.ts | 9 + web/src/core-bridge/worker/sessions.ts | 25 +++ web/src/core-bridge/worker/shareDraft.ts | 99 +++++++++++ web/src/core-bridge/worker/shareManifest.ts | 70 ++++++++ web/src/core-bridge/worker/shareRead.ts | 122 ++++++++++++++ web/src/core-bridge/worker/shareResources.ts | 108 ++++++++++++ web/src/core-bridge/worker/shareSeal.ts | 48 ++++++ web/src/core-bridge/worker/shareState.ts | 49 ++++++ web/src/core-bridge/worker/transfer.ts | 74 +++++++++ web/src/core-bridge/worker/transports.ts | 98 +++++++++++ web/src/core-bridge/worker/wire.ts | 54 ++++++ web/src/core-bridge/workerClient.ts | 72 ++++++++ web/src/features/channel/Workspace.module.css | 12 ++ web/src/features/channel/Workspace.tsx | 7 +- web/src/features/home/ChannelList.tsx | 2 +- web/src/features/home/CreateSheet.tsx | 6 +- web/src/features/home/Home.module.css | 34 +++- web/src/features/home/Home.tsx | 76 +++++---- web/src/features/home/QuickShare.module.css | 154 ++++++++++++++++-- web/src/features/home/QuickShare.tsx | 105 ++++++++---- web/src/features/item/InlineContent.tsx | 2 +- web/src/features/item/Tile.tsx | 2 +- web/src/features/item/source.ts | 2 +- .../features/share/DraftPreview.module.css | 94 +++++++++++ web/src/features/share/DraftPreview.tsx | 92 +++++++++++ web/src/features/share/DraftRow.module.css | 44 +++-- web/src/features/share/DraftRow.tsx | 26 +-- web/src/features/share/ShareBar.module.css | 1 + web/src/features/share/ShareDraft.module.css | 122 +++++++++++--- web/src/features/share/ShareDraft.tsx | 67 +++++--- web/src/features/share/ShareView.module.css | 7 + web/src/features/share/ShareView.tsx | 6 +- web/src/lib/channelLink.test.ts | 3 + web/src/lib/channelLink.ts | 1 + web/src/lib/shareLink.test.ts | 1 + web/src/lib/shareLink.ts | 1 + web/src/main.tsx | 3 + web/src/store/channels.ts | 21 ++- web/src/store/reduce.ts | 6 + web/src/store/shareReduce.test.ts | 2 +- web/src/store/shareReduce.ts | 8 +- web/src/store/shareSession.ts | 2 + web/src/store/shares.ts | 23 ++- web/tsconfig.app.json | 2 +- web/vite.config.ts | 8 +- 101 files changed, 3153 insertions(+), 253 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .woodpecker/ci.yml create mode 100644 .woodpecker/release-backend.yml create mode 100644 compose.yaml create mode 100644 contracts/api-contract.json create mode 100644 docs/deployment.md create mode 100644 scripts/check-api-contract.mjs create mode 100644 server/Dockerfile create mode 100644 server/src/WebDrop.Api/ApiContract.cs create mode 100644 web/src/app/entry.test.ts create mode 100644 web/src/core-bridge/captcha.ts create mode 100644 web/src/core-bridge/contract.test.ts create mode 100644 web/src/core-bridge/contract.ts create mode 100644 web/src/core-bridge/database.ts create mode 100644 web/src/core-bridge/errors.ts create mode 100644 web/src/core-bridge/real.ts create mode 100644 web/src/core-bridge/worker.ts create mode 100644 web/src/core-bridge/worker/channelEvents.ts create mode 100644 web/src/core-bridge/worker/channelItems.ts create mode 100644 web/src/core-bridge/worker/channelResources.ts create mode 100644 web/src/core-bridge/worker/channelSend.ts create mode 100644 web/src/core-bridge/worker/channelTransport.ts create mode 100644 web/src/core-bridge/worker/http.test.ts create mode 100644 web/src/core-bridge/worker/http.ts create mode 100644 web/src/core-bridge/worker/links.ts create mode 100644 web/src/core-bridge/worker/port.ts create mode 100644 web/src/core-bridge/worker/presentation.ts create mode 100644 web/src/core-bridge/worker/serial.ts create mode 100644 web/src/core-bridge/worker/sessions.ts create mode 100644 web/src/core-bridge/worker/shareDraft.ts create mode 100644 web/src/core-bridge/worker/shareManifest.ts create mode 100644 web/src/core-bridge/worker/shareRead.ts create mode 100644 web/src/core-bridge/worker/shareResources.ts create mode 100644 web/src/core-bridge/worker/shareSeal.ts create mode 100644 web/src/core-bridge/worker/shareState.ts create mode 100644 web/src/core-bridge/worker/transfer.ts create mode 100644 web/src/core-bridge/worker/transports.ts create mode 100644 web/src/core-bridge/worker/wire.ts create mode 100644 web/src/core-bridge/workerClient.ts create mode 100644 web/src/features/share/DraftPreview.module.css create mode 100644 web/src/features/share/DraftPreview.tsx diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c960744 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..37ed5b2 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 73e3d73..3354678 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ server/.idea/ **/*.db **/*.db-shm **/*.db-wal +.env +.env.* +!.env.example # Editor and OS files .vscode/ diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml new file mode 100644 index 0000000..25ddb72 --- /dev/null +++ b/.woodpecker/ci.yml @@ -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 diff --git a/.woodpecker/release-backend.yml b/.woodpecker/release-backend.yml new file mode 100644 index 0000000..aa4d815 --- /dev/null +++ b/.woodpecker/release-backend.yml @@ -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 diff --git a/README.md b/README.md index f460b92..62c8aae 100644 --- a/README.md +++ b/README.md @@ -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) | 协议测试向量的生成与使用 | ## 核心概念 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..9a005f5 --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/contracts/api-contract.json b/contracts/api-contract.json new file mode 100644 index 0000000..1587a66 --- /dev/null +++ b/contracts/api-contract.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/docs/api.md b/docs/api.md index fb3d1c4..aef0ca1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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=` -拉取条目的帧(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. 错误 diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..5e4b366 --- /dev/null +++ b/docs/deployment.md @@ -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// +CAPTCHA_SITEVERIFY_URL=https://captcha.example.com//siteverify +CAPTCHA_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 频道按设计保存明文负载。 diff --git a/docs/frontend.md b/docs/frontend.md index 0555973..ec7446e 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -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` 重建 | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 87ae402..88d0d1b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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] diff --git a/docs/protocol.md b/docs/protocol.md index b70c71a..f5af226 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 完成分享(封存) diff --git a/docs/ui-design.md b/docs/ui-design.md index 79d4dac..d71b596 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -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 [下载] │ +│ │ │ └──────────────────────┘ +│ │ 文本可直接编辑 │ +├───────────────┴────────────────┤ +│ 有效期 · 最多访客 · 下载次数 │ +│ □ 我已了解,完成后无法再修改 │ +│ [ 完成分享 ] │ +└────────────────────────────────┘ ``` | 阶段 | 说明 | |---|---| -| 草稿 | 创建者还握着所有者密钥:可以添加、移除、调整有效期、最多访客、每个文件下载次数。**此时没有分享链接**,不会被别人看到 | -| 完成分享 | 确认框写明「此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期;发错了也撤不回」。确认后生成链接与二维码 | +| 草稿 | 左侧列出内容,选择后在右侧预览;创建者还握着所有者密钥,可直接修改文本、替换或移除文件,并调整有效期、最多访客、每个文件下载次数。**此时没有分享链接**,不会被别人看到 | +| 完成分享 | 页面内勾选「完成后会生成分享链接和二维码。此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期」后,按钮才可用;点击即生成链接与二维码 | | 已封存(创建者) | 只读列表 + 「分享给别人」卡片(链接、二维码、说明) | | 已封存(访客) | 只读列表,只能预览、下载、复制;「全部下载」按钮 | diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 358e6b7..b4e5bc8 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -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'; diff --git a/packages/core/src/item.ts b/packages/core/src/item.ts index 5ed38ea..dbf0b18 100644 --- a/packages/core/src/item.ts +++ b/packages/core/src/item.ts @@ -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 { diff --git a/scripts/check-api-contract.mjs b/scripts/check-api-contract.mjs new file mode 100644 index 0000000..ac61848 --- /dev/null +++ b/scripts/check-api-contract.mjs @@ -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.`); diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..121f71e --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/src/WebDrop.Api/ApiContract.cs b/server/src/WebDrop.Api/ApiContract.cs new file mode 100644 index 0000000..319d267 --- /dev/null +++ b/server/src/WebDrop.Api/ApiContract.cs @@ -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"; +} diff --git a/server/src/WebDrop.Api/ApiJson.cs b/server/src/WebDrop.Api/ApiJson.cs index f92a96d..22d903b 100644 --- a/server/src/WebDrop.Api/ApiJson.cs +++ b/server/src/WebDrop.Api/ApiJson.cs @@ -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; } diff --git a/server/src/WebDrop.Core/Abstractions.cs b/server/src/WebDrop.Core/Abstractions.cs index 48376fc..deb5c82 100644 --- a/server/src/WebDrop.Core/Abstractions.cs +++ b/server/src/WebDrop.Core/Abstractions.cs @@ -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 endpointHash); + void AddDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan endpointHash); ShareFrame? GetShareFrame(Id16 shareId, Id16 itemId, uint index); void InsertShareFrame(ShareFrame frame); IReadOnlyList ListShareFrames(Id16 shareId, Id16 itemId, uint fromIndex, int limit); diff --git a/server/src/WebDrop.Core/ChannelService.cs b/server/src/WebDrop.Core/ChannelService.cs index c6f2862..6dfd803 100644 --- a/server/src/WebDrop.Core/ChannelService.cs +++ b/server/src/WebDrop.Core/ChannelService.cs @@ -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 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(); uint expected = fromIndex; diff --git a/server/src/WebDrop.Core/ShareService.cs b/server/src/WebDrop.Core/ShareService.cs index fac33cb..ff9b65e 100644 --- a/server/src/WebDrop.Core/ShareService.cs +++ b/server/src/WebDrop.Core/ShareService.cs @@ -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) diff --git a/server/src/WebDrop.Storage/SqliteChannelStore.cs b/server/src/WebDrop.Storage/SqliteChannelStore.cs index 55a447c..84ddf2a 100644 --- a/server/src/WebDrop.Storage/SqliteChannelStore.cs +++ b/server/src/WebDrop.Storage/SqliteChannelStore.cs @@ -10,7 +10,7 @@ namespace WebDrop.Storage; /// 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 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 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)); diff --git a/server/tests/WebDrop.Api.Tests/RestTests.cs b/server/tests/WebDrop.Api.Tests/RestTests.cs index 7592202..ab9c832 100644 --- a/server/tests/WebDrop.Api.Tests/RestTests.cs +++ b/server/tests/WebDrop.Api.Tests/RestTests.cs @@ -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 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 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 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 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"); diff --git a/server/tests/WebDrop.Api.Tests/ShareTests.cs b/server/tests/WebDrop.Api.Tests/ShareTests.cs index 884ebf1..866bb3d 100644 --- a/server/tests/WebDrop.Api.Tests/ShareTests.cs +++ b/server/tests/WebDrop.Api.Tests/ShareTests.cs @@ -119,6 +119,37 @@ public sealed class ShareTests(ServerFixture server) : IClassFixture CreateShare(ShareClient share) { using var req = server.Req(HttpMethod.Post, "/api/v1/shares", share.Owner, JsonBody(new diff --git a/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs b/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs index 306e615..58576f5 100644 --- a/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs +++ b/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs @@ -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); diff --git a/web/package-lock.json b/web/package-lock.json index 2a723b9..8c6f9e6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 790740b..bb397ba 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index fa56a4a..6ffc8c9 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -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 = ; + else if (route.name === 'share' && waitingOnSavedShare) screen = null; else if (route.name === 'share') screen = ; + else if (route.name === 'channel' && waitingOnSavedChannel) screen = null; else if (saved) screen = ; else if (pendingLink?.id === route.id) { screen = ( diff --git a/web/src/app/entry.test.ts b/web/src/app/entry.test.ts new file mode 100644 index 0000000..87136f5 --- /dev/null +++ b/web/src/app/entry.test.ts @@ -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`); + }); +}); diff --git a/web/src/app/entry.ts b/web/src/app/entry.ts index 4b188dc..893bd56 100644 --- a/web/src/app/entry.ts +++ b/web/src/app/entry.ts @@ -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 }; diff --git a/web/src/core-bridge/api.ts b/web/src/core-bridge/api.ts index 4d8e993..8a3487a 100644 --- a/web/src/core-bridge/api.ts +++ b/web/src/core-bridge/api.ts @@ -7,9 +7,12 @@ import type { ArchiveEntry, ChannelInfo, ConnectionState, CreateOptions, Workspa */ export interface WorkspaceApi { /** 新建频道:本地生成 id 与密钥、算 PoW、POST channels(api.md) */ - createChannel(opts: CreateOptions): Promise; + createChannel(opts: CreateOptions, captchaToken?: string): Promise; /** 通过链接加入:先 GET 频道信息预览,再 POST seats */ joinChannel(link: ParsedChannelLink): Promise; + listSaved(): Promise; + /** 仅从本机删除保存的链接和终端密钥,不会删服务端频道 */ + forget(id: string): Promise; /** 仅假实现提供:载入演示用的频道 */ loadDemo?(): ChannelInfo[]; diff --git a/web/src/core-bridge/captcha.ts b/web/src/core-bridge/captcha.ts new file mode 100644 index 0000000..df439ec --- /dev/null +++ b/web/src/core-bridge/captcha.ts @@ -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 { + 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[0] & { workers: number }; + const { token } = await new Cap(config).solve(); + if (!token) throw new Error('验证码未完成'); + return token; +} diff --git a/web/src/core-bridge/contract.test.ts b/web/src/core-bridge/contract.test.ts new file mode 100644 index 0000000..d153bc8 --- /dev/null +++ b/web/src/core-bridge/contract.test.ts @@ -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); + }); +}); diff --git a/web/src/core-bridge/contract.ts b/web/src/core-bridge/contract.ts new file mode 100644 index 0000000..ab367a7 --- /dev/null +++ b/web/src/core-bridge/contract.ts @@ -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; +} diff --git a/web/src/core-bridge/database.ts b/web/src/core-bridge/database.ts new file mode 100644 index 0000000..c683a05 --- /dev/null +++ b/web/src/core-bridge/database.ts @@ -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; + 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> | undefined; + +function db(): Promise> { + dbPromise ??= openDB('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 { + await (await db()).put('resources', resource); +} + +export async function getResource(id: string): Promise { + return (await db()).get('resources', id); +} + +export async function listResources(kind: SavedResource['kind']): Promise { + 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 { + 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 { + const key = `${resourceId}:${itemId}`; + await (await db()).put('draftFiles', { key, resourceId, itemId, file }); +} + +export async function getDraftFile(resourceId: string, itemId: string): Promise { + return (await db()).get('draftFiles', `${resourceId}:${itemId}`).then((record) => record?.file); +} + +export async function removeDraftFile(resourceId: string, itemId: string): Promise { + await (await db()).delete('draftFiles', `${resourceId}:${itemId}`); +} + +export async function clearDraftFiles(resourceId: string): Promise { + 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 { + await (await db()).put('outbox', frame); +} + +export async function queuedFrames(resourceId: string): Promise { + 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 { + await (await db()).delete('outbox', key); +} + +export async function nextQueuedFrame(resourceId: string): Promise { + const values = await (await db()).getAllFromIndex('outbox', 'by-resource', resourceId, 1); + return values[0]; +} + +export async function dequeueItem(resourceId: string, itemId: string): Promise { + 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; +} diff --git a/web/src/core-bridge/errors.ts b/web/src/core-bridge/errors.ts new file mode 100644 index 0000000..48eb8ef --- /dev/null +++ b/web/src/core-bridge/errors.ts @@ -0,0 +1,20 @@ +const messages: Record = { + 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 : '操作失败,请重试。'; +} diff --git a/web/src/core-bridge/instance.ts b/web/src/core-bridge/instance.ts index 450da9e..3f6a926 100644 --- a/web/src/core-bridge/instance.ts +++ b/web/src/core-bridge/instance.ts @@ -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; diff --git a/web/src/core-bridge/mock.ts b/web/src/core-bridge/mock.ts index c6a7f2d..ec3adcb 100644 --- a/web/src/core-bridge/mock.ts +++ b/web/src/core-bridge/mock.ts @@ -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, }; diff --git a/web/src/core-bridge/mockSeed.ts b/web/src/core-bridge/mockSeed.ts index 874d3ce..9ac15ea 100644 --- a/web/src/core-bridge/mockSeed.ts +++ b/web/src/core-bridge/mockSeed.ts @@ -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}`, }; diff --git a/web/src/core-bridge/mockShare.ts b/web/src/core-bridge/mockShare.ts index a2d8fa1..784dda5 100644 --- a/web/src/core-bridge/mockShare.ts +++ b/web/src/core-bridge/mockShare.ts @@ -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) { diff --git a/web/src/core-bridge/real.ts b/web/src/core-bridge/real.ts new file mode 100644 index 0000000..0e1a24e --- /dev/null +++ b/web/src/core-bridge/real.ts @@ -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('createChannel', { options, captchaToken, apiBase: API_BASE }), + joinChannel: (link: ParsedChannelLink) => callWorker('joinChannel', { link, apiBase: API_BASE }), + listSaved: () => callWorker('listChannels', { apiBase: API_BASE }), + forget: (id) => callWorker('forgetChannel', { id, apiBase: API_BASE }), + + connect(channelId, onEvent) { + activeChannelId = channelId; + activeShareId = ''; + const unsubscribe = subscribeWorker(channelId, onEvent); + void callWorker('connectChannel', { id: channelId, apiBase: API_BASE }).catch(() => reportChannelUnavailable(channelId, onEvent)); + return () => { + unsubscribe(); + void callWorker('stopChannel', { id: channelId, apiBase: API_BASE }); + }; + }, + + sendText: (text) => callWorker('sendText', { id: currentChannelId(), text, apiBase: API_BASE }), + sendFiles: (files) => callWorker('sendFiles', { id: currentChannelId(), files, apiBase: API_BASE }), + editText: (id, text) => callWorker('editChannelText', { id: currentChannelId(), itemId: id, text, apiBase: API_BASE }), + resolveConflict: (id, choice) => callWorker('resolveTextConflict', { id: currentChannelId(), itemId: id, choice, apiBase: API_BASE }), + remove: (id) => callWorker('removeChannelItem', { id: currentChannelId(), itemId: id, apiBase: API_BASE }), + setSnapshot: (text) => callWorker('updateSnapshot', { id: currentChannelId(), text, apiBase: API_BASE }), + getBlob: (id, versionId) => callWorker('getChannelBlob', { id: currentChannelId(), itemId: id, versionId, apiBase: API_BASE }), + listArchive: (_id: string): Promise => callWorker('listArchive', { id: currentChannelId(), apiBase: API_BASE }), + markSeen: (id) => callWorker('markChannelSeen', { id: currentChannelId(), itemId: id, apiBase: API_BASE }), +}; + +export const realShareApi: ShareApi = { + createDraft: (settings: ShareSettings, captchaToken?: string) => callWorker('createShare', { settings, captchaToken, apiBase: API_BASE }), + listSaved: () => callWorker('listShares', { apiBase: API_BASE }), + updateSettings: (id, patch) => callWorker('updateShareSettings', { id, patch, apiBase: API_BASE }), + seal: (id) => callWorker('sealShare', { id, apiBase: API_BASE }), + join: (link: ParsedShareLink) => callWorker('joinShare', { link, apiBase: API_BASE }), + forget: (id) => callWorker('forgetShare', { id, apiBase: API_BASE }), + + connect(shareId, onEvent) { + activeShareId = shareId; + activeChannelId = ''; + const unsubscribe = subscribeWorker(shareId, onEvent); + void callWorker('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('addShareText', { id: currentShareId(), text, apiBase: API_BASE }), + editText: (id, text) => callWorker('editShareText', { id: currentShareId(), itemId: id, text, apiBase: API_BASE }), + addFiles: (files) => callWorker('addShareFiles', { id: currentShareId(), files, apiBase: API_BASE }), + removeItem: (id) => callWorker('removeShareItem', { id: currentShareId(), itemId: id, apiBase: API_BASE }), + getBlob: (id) => callWorker('getShareBlob', { id: currentShareId(), itemId: id, apiBase: API_BASE }), + listArchive: (_id: string): Promise => callWorker('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 {} diff --git a/web/src/core-bridge/shareApi.ts b/web/src/core-bridge/shareApi.ts index 424e361..19fd4e8 100644 --- a/web/src/core-bridge/shareApi.ts +++ b/web/src/core-bridge/shareApi.ts @@ -6,8 +6,9 @@ import type { ArchiveEntry, Share, ShareEvent, ShareSettings } from './types'; * 「草稿 → 封存 → 过期」。线路上仍是 channels(成员不可上传),所以由同一个 core 会话实现。 */ export interface ShareApi { + listSaved(): Promise; /** 新建草稿:创建者握着所有者密钥。此时还没有可分享的链接 */ - createDraft(settings: ShareSettings): Promise; + createDraft(settings: ShareSettings, captchaToken?: string): Promise; /** 草稿阶段调整设置(PATCH settings,需所有者令牌) */ updateSettings(id: string, patch: Partial): Promise; /** @@ -17,6 +18,8 @@ export interface ShareApi { seal(id: string): Promise; /** 访客通过链接直接加入(不预览、不展示人数),返回这个分享 */ join(link: ParsedShareLink): Promise; + /** 仅从本机移除链接和终端密钥,不会删除已封存的远端分享 */ + forget(id: string): Promise; /** 仅假实现提供:载入演示用的分享 */ loadDemo?(): Share[]; @@ -24,6 +27,7 @@ export interface ShareApi { connect(shareId: string, onEvent: (e: ShareEvent) => void): () => void; /** 草稿阶段添加与移除内容 */ addText(text: string): Promise; + editText(id: string, text: string): Promise; addFiles(files: File[]): Promise; removeItem(id: string): Promise; /** 取内容(封存后每次拉取消耗一次下载,见 api.md) */ diff --git a/web/src/core-bridge/types.ts b/web/src/core-bridge/types.ts index 4ed7596..a5e5f26 100644 --- a/web/src/core-bridge/types.ts +++ b/web/src/core-bridge/types.ts @@ -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 }; diff --git a/web/src/core-bridge/worker.ts b/web/src/core-bridge/worker.ts new file mode 100644 index 0000000..1659570 --- /dev/null +++ b/web/src/core-bridge/worker.ts @@ -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) => 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 { + const payload = input as Record; + 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[0], typeof payload.captchaToken === 'string' ? payload.captchaToken : undefined); + case 'joinChannel': return joinChannel(payload as Parameters[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[0], typeof payload.captchaToken === 'string' ? payload.captchaToken : undefined); + case 'joinShare': return joinShare(payload as Parameters[0]); + case 'listShares': return listSavedShares(); + case 'updateShareSettings': return updateShareSettings(id, payload.patch as Parameters[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), + }), + ); +}; diff --git a/web/src/core-bridge/worker/channelEvents.ts b/web/src/core-bridge/worker/channelEvents.ts new file mode 100644 index 0000000..92ff4e2 --- /dev/null +++ b/web/src/core-bridge/worker/channelEvents.ts @@ -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 { + 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; + 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 { + const info = await readJson(`/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>( + `/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 { + 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 { + 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; +} diff --git a/web/src/core-bridge/worker/channelItems.ts b/web/src/core-bridge/worker/channelItems.ts new file mode 100644 index 0000000..cc09407 --- /dev/null +++ b/web/src/core-bridge/worker/channelItems.ts @@ -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 { + 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 { + 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>; + 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 { + throw new Error('此条目没有可合并的冲突版本'); +} + +export async function markChannelSeen(): Promise { + return undefined; +} + +export async function listArchive(_id: string): Promise { + throw new Error('服务端没有提供压缩包目录接口,可以直接下载。'); +} diff --git a/web/src/core-bridge/worker/channelResources.ts b/web/src/core-bridge/worker/channelResources.ts new file mode 100644 index 0000000..dd420de --- /dev/null +++ b/web/src/core-bridge/worker/channelResources.ts @@ -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 { + 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); + 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 { + 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(`/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 { + 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 { + stopChannel(id); + sessions.get(id)?.dispose(); + sessions.delete(id); + await removeResource(id); +} + diff --git a/web/src/core-bridge/worker/channelSend.ts b/web/src/core-bridge/worker/channelSend.ts new file mode 100644 index 0000000..ca31169 --- /dev/null +++ b/web/src/core-bridge/worker/channelSend.ts @@ -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 { + 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 { + 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 { + const created = await sendText(resourceId, text); + if (!created.uploaded) throw new Error('新版本已排队,网络恢复前保留旧内容。'); + await removeChannelItem(resourceId, itemId); +} + diff --git a/web/src/core-bridge/worker/channelTransport.ts b/web/src/core-bridge/worker/channelTransport.ts new file mode 100644 index 0000000..42f8197 --- /dev/null +++ b/web/src/core-bridge/worker/channelTransport.ts @@ -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 { + active.get(id)?.abort(); + const controller = new AbortController(); + active.set(id, controller); + const signal = controller.signal; + let flushTimer: ReturnType | 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); +} diff --git a/web/src/core-bridge/worker/http.test.ts b/web/src/core-bridge/worker/http.test.ts new file mode 100644 index 0000000..96f350b --- /dev/null +++ b/web/src/core-bridge/worker/http.test.ts @@ -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); + }); +}); diff --git a/web/src/core-bridge/worker/http.ts b/web/src/core-bridge/worker/http.ts new file mode 100644 index 0000000..c04c939 --- /dev/null +++ b/web/src/core-bridge/worker/http.ts @@ -0,0 +1,58 @@ +import { supportsApiContract } from '../contract'; + +export type ApiError = Error & { status?: number; code?: string }; +let apiBase = '/api/v1'; +let contractCheck: Promise | 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 { + 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 { + 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(path: string, headers: Record): Promise { + return apiFetch(path, { headers }).then((response) => response.json() as Promise); +} + diff --git a/web/src/core-bridge/worker/links.ts b/web/src/core-bridge/worker/links.ts new file mode 100644 index 0000000..c3114e5 --- /dev/null +++ b/web/src/core-bridge/worker/links.ts @@ -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 { + 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}` : ''}`; +} diff --git a/web/src/core-bridge/worker/port.ts b/web/src/core-bridge/worker/port.ts new file mode 100644 index 0000000..7639553 --- /dev/null +++ b/web/src/core-bridge/worker/port.ts @@ -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); +} diff --git a/web/src/core-bridge/worker/presentation.ts b/web/src/core-bridge/worker/presentation.ts new file mode 100644 index 0000000..e90bd31 --- /dev/null +++ b/web/src/core-bridge/worker/presentation.ts @@ -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, 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); +} diff --git a/web/src/core-bridge/worker/serial.ts b/web/src/core-bridge/worker/serial.ts new file mode 100644 index 0000000..45aeb01 --- /dev/null +++ b/web/src/core-bridge/worker/serial.ts @@ -0,0 +1,9 @@ +const running = new Map>(); + +export function serial(key: string, action: () => Promise): Promise { + 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; +} diff --git a/web/src/core-bridge/worker/sessions.ts b/web/src/core-bridge/worker/sessions.ts new file mode 100644 index 0000000..9572abb --- /dev/null +++ b/web/src/core-bridge/worker/sessions.ts @@ -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(); +export const readers = new Map>(); +export const active = new Map(); +export const blobs = new Map(); +const opening = new Map>(); +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 }; +} diff --git a/web/src/core-bridge/worker/shareDraft.ts b/web/src/core-bridge/worker/shareDraft.ts new file mode 100644 index 0000000..6e0e61e --- /dev/null +++ b/web/src/core-bridge/worker/shareDraft.ts @@ -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 } { + 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 { + 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 { + 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 = { 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 { + 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 { + 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 }); +} + diff --git a/web/src/core-bridge/worker/shareManifest.ts b/web/src/core-bridge/worker/shareManifest.ts new file mode 100644 index 0000000..7895185 --- /dev/null +++ b/web/src/core-bridge/worker/shareManifest.ts @@ -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 { + 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(); + 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 }; +} diff --git a/web/src/core-bridge/worker/shareRead.ts b/web/src/core-bridge/worker/shareRead.ts new file mode 100644 index 0000000..a7eabb0 --- /dev/null +++ b/web/src/core-bridge/worker/shareRead.ts @@ -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 { + 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 { + 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 { + 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(`/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>; + 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; +} + diff --git a/web/src/core-bridge/worker/shareResources.ts b/web/src/core-bridge/worker/shareResources.ts new file mode 100644 index 0000000..8921ee3 --- /dev/null +++ b/web/src/core-bridge/worker/shareResources.ts @@ -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 { + 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 { + 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(`/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): Promise { + const { session, resource } = await getSession(id); + if (resource.kind !== 'share' || !session.can('owner')) throw new Error('此分享不再可修改'); + const body: Record = {}; + 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); + 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 { + 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 { + 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, + })); +} diff --git a/web/src/core-bridge/worker/shareSeal.ts b/web/src/core-bridge/worker/shareSeal.ts new file mode 100644 index 0000000..d218866 --- /dev/null +++ b/web/src/core-bridge/worker/shareSeal.ts @@ -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 { + 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; +} diff --git a/web/src/core-bridge/worker/shareState.ts b/web/src/core-bridge/worker/shareState.ts new file mode 100644 index 0000000..9428d15 --- /dev/null +++ b/web/src/core-bridge/worker/shareState.ts @@ -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 { + const role = resource.state === 'draft' && session.can('owner') ? 'owner' : 'read'; + let raw: ShareWire; + try { raw = await readJson(`/shares/${resource.id}`, session.authHeaders(role)); } + catch (error) { + if (role !== 'owner' || (error as { status?: number }).status !== 401) throw error; + // finish 响应丢失时,读凭据仍可确认服务端已封存。 + raw = await readJson(`/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 { + 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 { + 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); +} diff --git a/web/src/core-bridge/worker/transfer.ts b/web/src/core-bridge/worker/transfer.ts new file mode 100644 index 0000000..331b8d5 --- /dev/null +++ b/web/src/core-bridge/worker/transfer.ts @@ -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 { + 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 { + return serial(`outbox:${resource.id}`, () => flush(resource, session)); +} + +async function flush(resource: SavedResource, session: ChannelSession): Promise { + 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 }; +} diff --git a/web/src/core-bridge/worker/transports.ts b/web/src/core-bridge/worker/transports.ts new file mode 100644 index 0000000..98cae67 --- /dev/null +++ b/web/src/core-bridge/worker/transports.ts @@ -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; + control(value: { t?: string; seq?: number }): Promise; + online(): void; +} + +export function socketStream(id: string, session: ChannelSession, signal: AbortSignal, consumer: StreamConsumer): Promise { + 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; + 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 { + 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 { + 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 }); + }); +} diff --git a/web/src/core-bridge/worker/wire.ts b/web/src/core-bridge/worker/wire.ts new file mode 100644 index 0000000..ef2ad85 --- /dev/null +++ b/web/src/core-bridge/worker/wire.ts @@ -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 }; +} diff --git a/web/src/core-bridge/workerClient.ts b/web/src/core-bridge/workerClient.ts new file mode 100644 index 0000000..d5b120c --- /dev/null +++ b/web/src/core-bridge/workerClient.ts @@ -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(); +const listeners = new Map void>>(); + +function getWorker(): Worker { + worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module', name: 'webdrop-crypto' }); + worker.onmessage = (message: MessageEvent) => { + 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(operation: string, payload: unknown = {}): Promise { + const requestId = nextId++; + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve: (value) => resolve(value as T), reject }); + getWorker().postMessage({ requestId, operation, payload }); + }); +} + +export function subscribeWorker(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 }; diff --git a/web/src/features/channel/Workspace.module.css b/web/src/features/channel/Workspace.module.css index 482690f..1f24f80 100644 --- a/web/src/features/channel/Workspace.module.css +++ b/web/src/features/channel/Workspace.module.css @@ -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); +} diff --git a/web/src/features/channel/Workspace.tsx b/web/src/features/channel/Workspace.tsx index b4ce6c9..a392f97 100644 --- a/web/src/features/channel/Workspace.tsx +++ b/web/src/features/channel/Workspace.tsx @@ -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() { )} + {error ?
{error}
: null} {!desktop && panelOpen ? ( diff --git a/web/src/features/home/ChannelList.tsx b/web/src/features/home/ChannelList.tsx index f4ba540..d28b8f3 100644 --- a/web/src/features/home/ChannelList.tsx +++ b/web/src/features/home/ChannelList.tsx @@ -36,7 +36,7 @@ export function ChannelList({ kind, title, onOpen }: Props) { 加密 {c.state === 'sealed' ? '已封存' : '草稿'} {c.mine ? '我创建的' : '收到的'} - {formatRemaining(c.createdAt + c.ttlSeconds * 1000)} + {formatRemaining(c.expiresAt)} ) : ( <> diff --git a/web/src/features/home/CreateSheet.tsx b/web/src/features/home/CreateSheet.tsx index 3f3adfc..01f2d3f 100644 --- a/web/src/features/home/CreateSheet.tsx +++ b/web/src/features/home/CreateSheet.tsx @@ -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({ kind: 'channel', mode: 'enc', seats: 3, ttlSeconds: 86400, membersCanUpload: true, maxDownloads: null }); + const [opts, setOpts] = useState({ mode: 'enc', seats: 3, ttlSeconds: 86400, membersCanUpload: true }); const [busy, setBusy] = useState(false); const [failed, setFailed] = useState(false); const set = (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); diff --git a/web/src/features/home/Home.module.css b/web/src/features/home/Home.module.css index 9f8ee54..9b7f71c 100644 --- a/web/src/features/home/Home.module.css +++ b/web/src/features/home/Home.module.css @@ -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); diff --git a/web/src/features/home/Home.tsx b/web/src/features/home/Home.tsx index b58ab5b..5cadacf 100644 --- a/web/src/features/home/Home.tsx +++ b/web/src/features/home/Home.tsx @@ -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([]); - 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 ( -
setDragging(false)} onDrop={onDrop}> +

WebDrop

在自己的设备之间快速传文件和文本,也可以分享给别人。

- setComposeOpen(true)} /> + {failed ?

创建分享失败,请重试。

: null} -
常驻频道:多台自己的设备之间同步
-
- - -
+
+
+

设备频道

+

多台自己的设备之间长期同步

+
+
+ + +
- + +

加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。

+
-

- 加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。 -

{import.meta.env.DEV ? ( ) : null} @@ -89,11 +96,8 @@ export function Home() { {createOpen ? setCreateOpen(false)} onCreated={open} /> : null} - {scanOpen ? setScanOpen(false)} onJoined={open} /> : null} + {scanOpen ? setScanOpen(false)} onChannel={open} onShare={open} /> : null} - {composeOpen ? ( - setComposeOpen(false)} onSubmit={(text) => void share({ files: [], text })} /> - ) : null}
); } diff --git a/web/src/features/home/QuickShare.module.css b/web/src/features/home/QuickShare.module.css index ef3410a..6433b6f 100644 --- a/web/src/features/home/QuickShare.module.css +++ b/web/src/features/home/QuickShare.module.css @@ -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%; } } diff --git a/web/src/features/home/QuickShare.tsx b/web/src/features/home/QuickShare.tsx index 7f49426..74e9112 100644 --- a/web/src/features/home/QuickShare.tsx +++ b/web/src/features/home/QuickShare.tsx @@ -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; } -/** 快速分享:拖入、粘贴或选择内容,自动创建一个临时加密频道并给出分享链接 */ -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(null); const galleryRef = useRef(null); const cameraRef = useRef(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) { + 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 ( -
- { pick(e.target.files); e.target.value = ''; }} /> - { pick(e.target.files); e.target.value = ''; }} /> - { pick(e.target.files); e.target.value = ''; }} /> +
{ 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} + > + { pick(event.target.files); event.target.value = ''; }} /> + { pick(event.target.files); event.target.value = ''; }} /> + { pick(event.target.files); event.target.value = ''; }} /> -

{busy ? '正在创建分享…' : '快速分享'}

-

- {touch ? '选择文件或文本' : '把文件拖到这里,或直接 Ctrl+V 粘贴文本'},做成一个分享列表发给别人下载。完成后内容只读,连你自己也不能再改。 -

-
- {buttons.filter((b) => !b.touchOnly || touch).map((b) => ( - - ))} -
+
+
+

快速分享

+

临时文本直接写在这里,也可以拖入文件。内容封存后只读。

+
+ 端到端加密 +
+ +