feat: integrate client and server sharing workflows
This commit is contained in:
1 parent
42b93ae2d8
commit
7406e2fe37
101 files changed
+3153
-253
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -16,6 +16,9 @@ server/.idea/
|
||||
**/*.db
|
||||
**/*.db-shm
|
||||
**/*.db-wal
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editor and OS files
|
||||
.vscode/
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) | 协议测试向量的生成与使用 |
|
||||
|
||||
## 核心概念
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"version": "1.0.0"
|
||||
}
|
||||
+4
-4
@@ -24,7 +24,7 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
|
||||
| 读日志、拉取条目 | `read`(enc)或知道 id(raw),并占有席位 |
|
||||
| 追加帧 | `write`(`members_can_upload` 为 true 时)或 `owner` |
|
||||
| 删除条目 | `owner`;`delete_policy=own` 时终端哈希等于条目作者;`anyone` 时任意已入席终端 |
|
||||
| 改设置、删频道、看和释放席位 | `owner` |
|
||||
| 改设置、删频道、看和释放席位 | `owner`;这些管理操作不占席位 |
|
||||
|
||||
## 3. 接口
|
||||
|
||||
@@ -52,7 +52,7 @@ raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创
|
||||
返回 `ChannelInfo`:设置、模式、席位占用、`oldest_seq`、`last_seq`、时间。**不占席位**,任意角色可调用,用于加入前预览和核对模式。
|
||||
|
||||
### `POST /api/v1/channels/{id}/seats`
|
||||
加入频道,占用一个席位。幂等:同一终端重复调用返回同一席位。席位已满返回 `403 seats_full`。除本接口和上面的「读取频道信息」外,其余频道接口都要求终端已入席,否则返回 `403 not_joined`。
|
||||
加入频道,占用一个席位。幂等:同一终端重复调用返回同一席位。席位已满返回 `403 seats_full`。读取频道信息和 owner 管理接口不占席位;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 `403 not_joined`。
|
||||
|
||||
### `PATCH /api/v1/channels/{id}/settings`
|
||||
所有者修改设置。`ttl_seconds` 的修改从现在起算。
|
||||
@@ -89,7 +89,7 @@ SSE 订阅(降级链的第二级)。记录以 base64url 放在 `data:` 中,`id:`
|
||||
WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20 秒,45 秒收不到任何消息即判定死链。重连使用指数退避(1 秒到 30 秒)加抖动,并携带 `from=last_seq+1`。
|
||||
|
||||
### `GET /api/v1/channels/{id}/items/{item_id}/frames?from=<index>`
|
||||
拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载,`from>0` 视为续传不计数。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
|
||||
拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载;`from>0` 只有同一终端此前对同一条目成功启动过 `from=0` 才能续传,否则返回 `410 gone`。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
|
||||
|
||||
### `DELETE /api/v1/channels/{id}/items/{item_id}`
|
||||
按 `delete_policy` 授权后删除条目,并在日志中追加 tombstone。
|
||||
@@ -166,7 +166,7 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
|
||||
|
||||
### `GET /api/v1/shares/{id}/items/{item_id}/frames`
|
||||
|
||||
与频道的 GATED 拉取一致:`from=0` 计一次下载,`from>0` 视为续传不计数;达到上限后返回 `410 gone`,5 分钟宽限期内已开始的拉取可继续续传。必须带 `X-WD-Endpoint`,同样会登记访客。
|
||||
与频道的 GATED 拉取一致:`from=0` 计一次下载;`from>0` 只有同一 `X-WD-Endpoint` 此前对同一文件成功启动过 `from=0` 才能续传,否则返回 `410 gone` 且不登记访客。达到上限后返回 `410 gone`,5 分钟宽限期内已开始的拉取可继续续传。必须带 `X-WD-Endpoint`,同样会登记访客。
|
||||
|
||||
## 5. 错误
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# 本地后端 Docker Compose
|
||||
|
||||
Compose 当前只启动 WebDrop API。前端继续使用现有的开发/独立托管方式;本地 Vite 开发服务器通过 `/api/v1` 代理访问 `127.0.0.1:5000`。API 默认只绑定 loopback,SQLite 数据保存在项目的 `./data` 目录。
|
||||
|
||||
## 启动
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
docker compose config
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
API 地址为 `http://127.0.0.1:5000`。停止服务使用 `docker compose down`;数据库和服务器存储的帧会保留在 `./data`。删除该目录会永久删除这些数据。
|
||||
|
||||
## 前端连接
|
||||
|
||||
本机 `npm run dev` 使用 Vite 代理连接默认 API。独立部署前端时,在前端托管层把 `/api/v1` 代理到此 API 地址,这样无需开放 CORS。也可以为跨域托管配置 `WebDrop__CorsOrigins`,值应为明确的前端源列表。
|
||||
|
||||
公网部署时,在 API 前配置 HTTPS 反向代理,并让代理连接 API 的 loopback 端口。不要把未加密 HTTP 直接暴露到公网。只有当该 API 端口仅能由可信代理访问时,才将 `WEBDROP_TRUST_FORWARDED_HEADERS` 设为 `true`。
|
||||
|
||||
## 验证码
|
||||
|
||||
默认 `CAPTCHA_PROVIDER=none`。启用 Cap 时,在 `.env` 设置:
|
||||
|
||||
```dotenv
|
||||
CAPTCHA_PROVIDER=cap
|
||||
CAPTCHA_API_ENDPOINT=https://captcha.example.com/<site-key>/
|
||||
CAPTCHA_SITEVERIFY_URL=https://captcha.example.com/<site-key>/siteverify
|
||||
CAPTCHA_SECRET=<server-side-secret>
|
||||
```
|
||||
|
||||
`CAPTCHA_API_ENDPOINT` 会提供给浏览器;`CAPTCHA_SITEVERIFY_URL` 和 `CAPTCHA_SECRET` 仅供 API 容器使用。不要提交 `.env`。
|
||||
|
||||
## Woodpecker CI
|
||||
|
||||
`.woodpecker/ci.yml` 在 push 和 pull request 上运行 API 契约、Core、Web、服务端检查,并解析后端 Compose 配置。每次更新都会构建前端。`.woodpecker/release-backend.yml` 仅响应 `vMAJOR.MINOR.PATCH` SemVer tag;测试通过后构建并推送同名后端镜像 tag。
|
||||
|
||||
后端镜像发布到 `zot.oeo.one/justme/webdrop-api`。在 Woodpecker 仓库配置 tag 事件可用的 secrets `registry_username`、`registry_password`。Docker Buildx 发布步骤以特权模式运行;Woodpecker Agent 必须把 `woodpeckerci/plugin-docker-buildx:2` 加入 `WOODPECKER_PLUGINS_PRIVILEGED`。使用已发布镜像时,在 `.env` 中设置 `WEBDROP_IMAGE_TAG`;仓库名已填入 `.env.example`。
|
||||
|
||||
API 契约版本以 `contracts/api-contract.json` 为准,并同步到 OpenAPI 与后端 `/api/v1/info`。加字段等向后兼容更新递增 minor,兼容性修复递增 patch,破坏性更新递增 major。前端声明支持的契约主版本;后端主版本变化时,前端 CI 契约检查和运行时连接检查都会失败,直到前端同步支持该主版本。
|
||||
|
||||
## 数据备份
|
||||
|
||||
停止服务或使用一致性快照后备份 `./data`。目录里保存 SQLite 元数据和服务端收到的帧字节;加密频道/分享的内容帧是密文。备份仍需按敏感数据保护,因为 raw 频道按设计保存明文负载。
|
||||
+3
-2
@@ -10,7 +10,7 @@
|
||||
| 语言 | TypeScript,`strict` + `noUncheckedIndexedAccess` | |
|
||||
| UI | React 19.3(稳定版,含 `ViewTransition`、`Activity`) | 不使用 canary |
|
||||
| 状态 | Zustand | 按领域拆 store |
|
||||
| 本地存储 | `idb`(IndexedDB) | 发送队列、「我的频道」 |
|
||||
| 本地存储 | `idb`(IndexedDB) | 发送队列、「我的频道」、草稿预览所需的本机文件 |
|
||||
| 样式 | 原生 CSS + CSS 变量 + CSS Modules | 无 Tailwind、无 CSS-in-JS |
|
||||
| 路由 | 自写极简导航(`app/navigation.ts`) | 只有两三个路由,且需统一挂 view transition |
|
||||
| 测试 | Vitest(单元)、Playwright(端到端) | |
|
||||
@@ -90,6 +90,7 @@ type ItemState =
|
||||
| K 导入为不可导出的 `CryptoKey` | 内存中不保留原始字节 |
|
||||
| 降级防御 | 链接含 `k` 就只接受 enc 且认证通过的帧 |
|
||||
| 不把密钥、令牌、明文写入 `console`、错误上报、URL、`localStorage` | 「我的频道」只存 IndexedDB |
|
||||
| 便捷分享草稿预览 | 原始文件只缓存在创建者本机 IndexedDB,不会随文件帧上传;移除条目或封存时清理,用于刷新后继续预览草稿 |
|
||||
| 条目预览在沙箱 iframe 渲染;raw 内容只当纯文本显示或下载 | 无内联渲染,不使用 `dangerouslySetInnerHTML` |
|
||||
| 严格 CSP,所有脚本样式带 SRI,不使用内联脚本 | 与 architecture §5 一致 |
|
||||
| 不引入任何第三方脚本、统计、字体 CDN | 字体自托管 |
|
||||
@@ -168,6 +169,6 @@ type ItemState =
|
||||
| 文件下载次数 | 每个条目的 `maxDownloads` 会强制 GATED,并带 `X-WD-Max-Downloads`,**必须随第 0 帧发送**。文本不要设,否则读文本也要拉取 |
|
||||
| 内联渲染不烧次数 | GATED 条目每次 `GET /items/{id}/frames?from=0` 计一次下载;自动加载只用于非 GATED(见 ui-design §6.5) |
|
||||
| 终端密钥 | `session.endpointSecret` 必须持久化到 IndexedDB,否则每次打开都占一个新席位。原型暂未做 |
|
||||
| 创建频道的验证码 | 读 `GET /info` 的 `captcha`,用 Cap 求解后放进 `X-WD-Cap` |
|
||||
| 创建频道、便捷分享的验证码 | 读 `GET /info` 的 `captcha`;需要时按需加载打包在前端的官方 `cap-widget`,求解后只把一次性 token 放进创建请求的 `X-WD-Cap`。不从 CDN 加载脚本 |
|
||||
| 校验失败 | reader 的 `rejected` 事件不能忽略,至少在界面提示「内容校验失败」 |
|
||||
| Worker | `ChannelSession` 不能跨线程,在 Worker 里用链接和 `endpointSecret` 重建 |
|
||||
+11
-8
@@ -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]
|
||||
|
||||
@@ -144,6 +144,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
**推送规则**:
|
||||
- 非 GATED 条目以 frame 记录推送给订阅者。服务端强制:**非 GATED 条目必须是单帧且带 FINAL**(`frame_index = 0`)。
|
||||
- GATED 条目只推送一条 announce,字节通过条目拉取接口取得(见 api.md),`from=0` 的拉取计入下载次数。
|
||||
- `from>0` 只有在同一终端此前已对同一条目成功发起 `from=0` 后才是有效续传;服务端按资源、条目和终端绑定这份续传许可,任意跳到中间帧不能绕过下载次数。
|
||||
- 因此客户端上传**多帧条目(文件)或带下载上限的条目时,必须设置 GATED**。
|
||||
- 同一条目的所有帧,SNAPSHOT 与 GATED 标志必须一致;FINAL 不能早于已存在的更大序号;已有 FINAL 之后不能再追加更大序号的帧。GATED 条目的帧允许乱序到达(并行上传),条目拉取只返回从起点开始连续的帧。
|
||||
|
||||
@@ -276,6 +277,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
- 创建者在浏览器里为每个文件或文本生成 `item_id`,加密成帧,逐帧 `POST /shares/{id}/frames`。帧格式、IV、AAD、padding、幂等重发与 §4–§7 完全相同,所以**断点续传和重试同样安全**。
|
||||
- 文件条目必须是 GATED(多帧);**不允许 SNAPSHOT 标志**;文本不单独成条目,内联进清单。
|
||||
- 文件的**下载次数上限**通过第 0 帧的 `X-WD-Max-Downloads` 设置,未设置时用分享设置里的 `file_max_downloads`。
|
||||
- `from>0` 续传必须来自同一 `X-WD-Endpoint` 对同一文件此前启动的 `from=0` 下载;否则服务端返回 `410 gone` 且不登记访客。
|
||||
- 草稿在服务端有 24 小时的存活期(可配置),防止遗弃的草稿一直占着空间。草稿阶段不消耗有效期。
|
||||
|
||||
### 15.5 完成分享(封存)
|
||||
|
||||
+27
-22
@@ -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 [下载] │
|
||||
│ │ │ └──────────────────────┘
|
||||
│ │ 文本可直接编辑 │
|
||||
├───────────────┴────────────────┤
|
||||
│ 有效期 · 最多访客 · 下载次数 │
|
||||
│ □ 我已了解,完成后无法再修改 │
|
||||
│ [ 完成分享 ] │
|
||||
└────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 阶段 | 说明 |
|
||||
|---|---|
|
||||
| 草稿 | 创建者还握着所有者密钥:可以添加、移除、调整有效期、最多访客、每个文件下载次数。**此时没有分享链接**,不会被别人看到 |
|
||||
| 完成分享 | 确认框写明「此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期;发错了也撤不回」。确认后生成链接与二维码 |
|
||||
| 草稿 | 左侧列出内容,选择后在右侧预览;创建者还握着所有者密钥,可直接修改文本、替换或移除文件,并调整有效期、最多访客、每个文件下载次数。**此时没有分享链接**,不会被别人看到 |
|
||||
| 完成分享 | 页面内勾选「完成后会生成分享链接和二维码。此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期」后,按钮才可用;点击即生成链接与二维码 |
|
||||
| 已封存(创建者) | 只读列表 + 「分享给别人」卡片(链接、二维码、说明) |
|
||||
| 已封存(访客) | 只读列表,只能预览、下载、复制;「全部下载」按钮 |
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.`);
|
||||
@@ -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"]
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -82,6 +82,8 @@ public interface IResourceTx
|
||||
void InsertShareItem(ShareItem item);
|
||||
void UpdateShareItem(ShareItem item);
|
||||
void DeleteShareItemRows(Id16 shareId, Id16 itemId);
|
||||
bool HasDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash);
|
||||
void AddDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash);
|
||||
ShareFrame? GetShareFrame(Id16 shareId, Id16 itemId, uint index);
|
||||
void InsertShareFrame(ShareFrame frame);
|
||||
IReadOnlyList<ShareFrame> ListShareFrames(Id16 shareId, Id16 itemId, uint fromIndex, int limit);
|
||||
|
||||
@@ -324,6 +324,9 @@ public sealed class ChannelService(
|
||||
if (!store.Read(tx => tx.BlobKeyExists(blobKey))) blobs.Delete(blobKey);
|
||||
}
|
||||
|
||||
private static bool IsItemReadable(Item item, long now) =>
|
||||
item.ExpiresAt > now && (item.CloseDeadline is null || item.CloseDeadline > now);
|
||||
|
||||
// ───────────────────────── 读取 ─────────────────────────
|
||||
|
||||
public async Task<LogPage> ReadLogAsync(Id16 id, Credentials creds, long from, int limit, CancellationToken ct = default)
|
||||
@@ -338,16 +341,19 @@ public sealed class ChannelService(
|
||||
if (from > 0 && from < channel.CompactedThrough + 1)
|
||||
throw new WebDropException(ErrorCode.Gone, channel.CompactedThrough + 1);
|
||||
|
||||
long now = clock.NowMs;
|
||||
var list = new List<(long Seq, byte[]? Bytes, StoredFrame? Frame)>();
|
||||
foreach (var e in tx.ReadLog(id, from, limit))
|
||||
{
|
||||
switch (e.Kind)
|
||||
{
|
||||
case LogKind.Frame:
|
||||
if (tx.GetFrame(id, e.ItemId, e.FrameIndex) is { } f) list.Add((e.Seq, null, f));
|
||||
if (tx.GetItem(id, e.ItemId) is { } frameItem && IsItemReadable(frameItem, now)
|
||||
&& tx.GetFrame(id, e.ItemId, e.FrameIndex) is { } f)
|
||||
list.Add((e.Seq, null, f));
|
||||
break;
|
||||
case LogKind.Announce:
|
||||
if (tx.GetItem(id, e.ItemId) is { } it)
|
||||
if (tx.GetItem(id, e.ItemId) is { } it && IsItemReadable(it, now))
|
||||
list.Add((e.Seq, LogRecord.EncodeAnnounce(e.Seq, e.ItemId, it.Flags, it.CreatedAt), null));
|
||||
break;
|
||||
case LogKind.Tombstone:
|
||||
@@ -378,7 +384,10 @@ public sealed class ChannelService(
|
||||
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
|
||||
long now = clock.NowMs;
|
||||
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now > deadline))
|
||||
if (item.ExpiresAt <= now) throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline))
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.IsGated && fromIndex > 0 && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash))
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
|
||||
if (fromIndex == 0 && item.IsGated)
|
||||
@@ -391,6 +400,7 @@ public sealed class ChannelService(
|
||||
? now + limits.DownloadGraceSeconds * 1000L
|
||||
: item.CloseDeadline;
|
||||
tx.UpdateItem(item with { Downloads = downloads, CloseDeadline = closeAt });
|
||||
tx.AddDownloadGrant(id, itemId, principal.EndpointHash);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
@@ -406,7 +416,10 @@ public sealed class ChannelService(
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (item.CloseDeadline is { } d && clock.NowMs > d) throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.IsGated && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash))
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.CloseDeadline is { } d && clock.NowMs >= d) throw new WebDropException(ErrorCode.Gone);
|
||||
|
||||
var taken = new List<StoredFrame>();
|
||||
uint expected = fromIndex;
|
||||
|
||||
@@ -210,11 +210,14 @@ public sealed class ShareService(
|
||||
store.Write(tx =>
|
||||
{
|
||||
var share = SealedForRead(tx.GetShare(id), creds);
|
||||
RegisterVisitor(tx, share, creds.EndpointHash);
|
||||
var item = tx.GetShareItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (!item.IsGated) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (fromIndex > 0 && !tx.HasDownloadGrant(id, itemId, creds.EndpointHash))
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
long now = clock.NowMs;
|
||||
if (item.CloseDeadline is { } d && (fromIndex == 0 || now > d)) throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline))
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
RegisterVisitor(tx, share, creds.EndpointHash);
|
||||
if (fromIndex == 0)
|
||||
{
|
||||
if (item.MaxDownloads > 0 && item.Downloads >= item.MaxDownloads) throw new WebDropException(ErrorCode.Gone);
|
||||
@@ -223,6 +226,7 @@ public sealed class ShareService(
|
||||
? now + limits.DownloadGraceSeconds * 1000L
|
||||
: item.CloseDeadline;
|
||||
tx.UpdateShareItem(item with { Downloads = count, CloseDeadline = closeAt });
|
||||
tx.AddDownloadGrant(id, itemId, creds.EndpointHash);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
@@ -234,6 +238,10 @@ public sealed class ShareService(
|
||||
{
|
||||
_ = SealedForRead(tx.GetShare(id), creds);
|
||||
var item = tx.GetShareItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (item.IsGated && !tx.HasDownloadGrant(id, itemId, creds.EndpointHash))
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.CloseDeadline is { } deadline && clock.NowMs >= deadline)
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
var list = tx.ListShareFrames(id, itemId, fromIndex, 32);
|
||||
uint expected = fromIndex;
|
||||
foreach (var frame in list)
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace WebDrop.Storage;
|
||||
/// </summary>
|
||||
public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
{
|
||||
private const int SchemaVersion = 2;
|
||||
private const int SchemaVersion = 3;
|
||||
|
||||
private readonly string _connectionString;
|
||||
private readonly SqliteConnection _writeConn;
|
||||
@@ -93,6 +93,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
using var tx = _writeConn.BeginTransaction();
|
||||
if (version == 0) Exec(_writeConn, SchemaV1);
|
||||
if (version < 2) Exec(_writeConn, SchemaV2);
|
||||
if (version < 3) Exec(_writeConn, SchemaV3);
|
||||
Exec(_writeConn, $"PRAGMA user_version={SchemaVersion};");
|
||||
tx.Commit();
|
||||
}
|
||||
@@ -222,6 +223,16 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
CREATE INDEX ix_share_frames_blob ON share_frames(blob_key) WHERE blob_key IS NOT NULL;
|
||||
""";
|
||||
|
||||
private const string SchemaV3 = """
|
||||
CREATE TABLE download_grants (
|
||||
resource_id BLOB NOT NULL,
|
||||
item_id BLOB NOT NULL,
|
||||
endpoint_hash BLOB NOT NULL,
|
||||
PRIMARY KEY (resource_id, item_id, endpoint_hash)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX ix_download_grants_resource ON download_grants(resource_id);
|
||||
""";
|
||||
|
||||
private sealed class SqliteTx(SqliteConnection conn) : IResourceTx
|
||||
{
|
||||
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes";
|
||||
@@ -319,6 +330,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
public void DeleteShareRows(Id16 id)
|
||||
{
|
||||
byte[] key = id.ToArray();
|
||||
Run("DELETE FROM download_grants WHERE resource_id=@id", ("@id", key));
|
||||
Run("DELETE FROM share_frames WHERE share_id=@id", ("@id", key));
|
||||
Run("DELETE FROM share_items WHERE share_id=@id", ("@id", key));
|
||||
Run("DELETE FROM share_visitors WHERE share_id=@id", ("@id", key));
|
||||
@@ -351,10 +363,19 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@s", i.ShareId.ToArray()), ("@i", i.ItemId.ToArray()));
|
||||
public void DeleteShareItemRows(Id16 shareId, Id16 itemId)
|
||||
{
|
||||
Run("DELETE FROM download_grants WHERE resource_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
|
||||
Run("DELETE FROM share_frames WHERE share_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
|
||||
Run("DELETE FROM share_items WHERE share_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
|
||||
}
|
||||
|
||||
public bool HasDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash) => Scalar(
|
||||
"SELECT 1 FROM download_grants WHERE resource_id=@r AND item_id=@i AND endpoint_hash=@e",
|
||||
("@r", resourceId.ToArray()), ("@i", itemId.ToArray()), ("@e", endpointHash.ToArray())) is not null;
|
||||
|
||||
public void AddDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash) => Run(
|
||||
"INSERT OR IGNORE INTO download_grants (resource_id,item_id,endpoint_hash) VALUES (@r,@i,@e)",
|
||||
("@r", resourceId.ToArray()), ("@i", itemId.ToArray()), ("@e", endpointHash.ToArray()));
|
||||
|
||||
private static ShareFrame MapShareFrame(SqliteDataReader r) => new(
|
||||
IdAt(r, 0), IdAt(r, 1), (uint)r.GetInt64(2), r.GetInt64(3), r.GetInt32(4), Blob(r, 5), BlobOrNull(r, 6),
|
||||
r.IsDBNull(7) ? null : r.GetString(7));
|
||||
@@ -411,6 +432,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
public void DeleteChannelRows(Id16 id)
|
||||
{
|
||||
byte[] key = id.ToArray();
|
||||
Run("DELETE FROM download_grants WHERE resource_id=@c", ("@c", key));
|
||||
Run("DELETE FROM frames WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM log WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM items WHERE channel_id=@c", ("@c", key));
|
||||
@@ -476,6 +498,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
public void DeleteItemRows(Id16 channelId, Id16 itemId)
|
||||
{
|
||||
byte[] c = channelId.ToArray(), i = itemId.ToArray();
|
||||
Run("DELETE FROM download_grants WHERE resource_id=@c AND item_id=@i", ("@c", c), ("@i", i));
|
||||
Run("DELETE FROM frames WHERE channel_id=@c AND item_id=@i", ("@c", c), ("@i", i));
|
||||
// 墓碑保留,其余记录(帧、announce)随条目一起删除
|
||||
Run("DELETE FROM log WHERE channel_id=@c AND item_id=@i AND kind<>@t", ("@c", c), ("@i", i), ("@t", (int)LogKind.Tombstone));
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using WebDrop.Api;
|
||||
using WebDrop.Core;
|
||||
using static WebDrop.Api.Tests.ServerFixture;
|
||||
|
||||
@@ -17,6 +19,7 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
|
||||
var json = await ReadJsonAsync(resp);
|
||||
Assert.Equal(1, json.GetProperty("protocol").GetInt32());
|
||||
Assert.Equal(ApiContract.Version, json.GetProperty("contract_version").GetString());
|
||||
Assert.True(json.GetProperty("transports").GetProperty("websocket").GetBoolean());
|
||||
Assert.Equal(1048592, json.GetProperty("limits").GetProperty("max_payload_bytes").GetInt32());
|
||||
// 默认不启用验证码,不下发 Cap 的地址
|
||||
@@ -89,6 +92,31 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "not_joined");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OwnerCapabilityCanManageWithoutOccupyingAnotherSeat()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
using (var joined = await s.JoinAsync(ch, ch.Member)) Assert.Equal(HttpStatusCode.OK, joined.StatusCode);
|
||||
var unseatedOwner = new Who("owner", ch.OwnerToken, RandomNumberGenerator.GetBytes(32));
|
||||
async Task<HttpResponseMessage> OwnerRequest(HttpMethod method, string path, HttpContent? body = null)
|
||||
{
|
||||
using var request = s.Req(method, path, unseatedOwner, body);
|
||||
request.Headers.Remove("X-WD-Endpoint");
|
||||
return await s.Http.SendAsync(request);
|
||||
}
|
||||
|
||||
using (var patch = await OwnerRequest(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", JsonBody(new { seats = 3 })))
|
||||
Assert.Equal(HttpStatusCode.OK, patch.StatusCode);
|
||||
using (var seats = await OwnerRequest(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats"))
|
||||
Assert.Equal(HttpStatusCode.OK, seats.StatusCode);
|
||||
using (var absentSeat = await OwnerRequest(HttpMethod.Delete,
|
||||
$"/api/v1/channels/{ch.Id}/seats/{B64(RandomNumberGenerator.GetBytes(32))}"))
|
||||
await AssertErrorAsync(absentSeat, 404, "not_found");
|
||||
using (var deleted = await OwnerRequest(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}"))
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner)), 404, "not_found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendIsIdempotentOverHttp()
|
||||
{
|
||||
@@ -287,6 +315,7 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
public async Task DeleteChannelRemovesIt()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
using (var joined = await s.JoinAsync(ch, ch.Member)) Assert.Equal(HttpStatusCode.OK, joined.StatusCode);
|
||||
await s.AppendAsync(ch, ch.Owner, Small(NewId()));
|
||||
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}", ch.Member)), 403, "forbidden");
|
||||
|
||||
@@ -119,6 +119,37 @@ public sealed class ShareTests(ServerFixture server) : IClassFixture<ServerFixtu
|
||||
await AssertErrorAsync(shareCollision, 409, "conflict");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeWithoutAnInitialDownloadCannotBypassShareDownloadLimit()
|
||||
{
|
||||
var share = new ShareClient(NewId());
|
||||
using (var created = await CreateShare(share)) Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
|
||||
string manifestId = NewId();
|
||||
using (var manifest = await SendFrame(share, share.Owner, Frame(manifestId, 0, FinalEnc, EncPayload)))
|
||||
Assert.Equal(HttpStatusCode.Created, manifest.StatusCode);
|
||||
string itemId = NewId();
|
||||
using (var first = await SendFrame(share, share.Owner, Frame(itemId, 0, GatedEnc, EncPayload), "1"))
|
||||
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
||||
using (var last = await SendFrame(share, share.Owner, Frame(itemId, 1, GatedFinalEnc, EncPayload)))
|
||||
Assert.Equal(HttpStatusCode.Created, last.StatusCode);
|
||||
using (var finish = await server.Http.SendAsync(server.Req(HttpMethod.Post,
|
||||
$"/api/v1/shares/{share.Id}/finish", share.Owner, JsonBody(new { manifest_item_id = manifestId }))))
|
||||
Assert.Equal(HttpStatusCode.OK, finish.StatusCode);
|
||||
|
||||
string path = $"/api/v1/shares/{share.Id}/items/{itemId}/frames";
|
||||
await AssertErrorAsync(await server.Http.SendAsync(server.Req(HttpMethod.Get, path + "?from=1", share.OtherVisitor)), 410, "gone");
|
||||
|
||||
var (initialResponse, initialBytes) = await server.GetBytesAsync(path, share.Visitor);
|
||||
using (initialResponse) Assert.Equal(Frame(itemId, 0, GatedEnc, EncPayload, 0x42).Concat(Frame(itemId, 1, GatedFinalEnc, EncPayload, 0x42)), initialBytes);
|
||||
var (resumeResponse, resumedBytes) = await server.GetBytesAsync(path + "?from=1", share.Visitor);
|
||||
using (resumeResponse) Assert.Equal(Frame(itemId, 1, GatedFinalEnc, EncPayload, 0x42), resumedBytes);
|
||||
|
||||
using var infoResponse = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/shares/{share.Id}", share.CreatorReader));
|
||||
var item = (await ReadJsonAsync(infoResponse)).GetProperty("items").EnumerateArray().Single();
|
||||
Assert.Equal(0, item.GetProperty("downloads_left").GetInt32());
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> CreateShare(ShareClient share)
|
||||
{
|
||||
using var req = server.Req(HttpMethod.Post, "/api/v1/shares", share.Owner, JsonBody(new
|
||||
|
||||
@@ -114,6 +114,26 @@ public class ChannelServiceTests : IDisposable
|
||||
await AssertCode(ErrorCode.NotJoined, () => LogOf(ch, ch.Reader(_memberEp)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ItemIdsAndFramesAreScopedToTheirChannel()
|
||||
{
|
||||
var otherOwnerEndpoint = TestEnv.Endpoint();
|
||||
var first = _env.Create(_ownerEp);
|
||||
var second = _env.Create(otherOwnerEndpoint);
|
||||
var sharedItemId = TestEnv.NewId();
|
||||
var firstOnlyItemId = TestEnv.NewId();
|
||||
await AppendSmall(first, first.Owner(_ownerEp), sharedItemId, fill: 0x11);
|
||||
await AppendSmall(second, second.Owner(otherOwnerEndpoint), sharedItemId, fill: 0x22);
|
||||
await AppendSmall(first, first.Owner(_ownerEp), firstOnlyItemId, fill: 0x33);
|
||||
|
||||
var firstFrame = (await Svc.ReadItemFramesAsync(first.Id, first.Owner(_ownerEp), sharedItemId, 0, 10)).Frames.Single();
|
||||
var secondFrame = (await Svc.ReadItemFramesAsync(second.Id, second.Owner(otherOwnerEndpoint), sharedItemId, 0, 10)).Frames.Single();
|
||||
Assert.Equal(0x11, firstFrame[^1]);
|
||||
Assert.Equal(0x22, secondFrame[^1]);
|
||||
await AssertCode(ErrorCode.NotFound,
|
||||
() => Svc.ReadItemFramesAsync(second.Id, second.Owner(otherOwnerEndpoint), firstOnlyItemId, 0, 10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReleasedSeatLosesAccess()
|
||||
{
|
||||
@@ -143,6 +163,22 @@ public class ChannelServiceTests : IDisposable
|
||||
Assert.Equal(2, seats.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnerCapabilityCanManageWithoutOccupyingAnotherSeat()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
Svc.Join(ch.Id, ch.Reader(_memberEp));
|
||||
var unseatedOwner = ch.Owner(TestEnv.Endpoint());
|
||||
|
||||
// Owner management uses A; an owner on a new device can manage a full channel without first taking a seat.
|
||||
Assert.Equal(ch.Id.ToBase64Url(), Svc.GetChannel(ch.Id, unseatedOwner).Id);
|
||||
Assert.Equal(3, Svc.UpdateSettings(ch.Id, unseatedOwner, new ChannelSettingsPatch { Seats = 3 }).Settings.Seats);
|
||||
Assert.Equal(2, Svc.ListSeats(ch.Id, unseatedOwner).Count);
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.ReleaseSeat(ch.Id, unseatedOwner, TestEnv.Endpoint()));
|
||||
Svc.DeleteChannel(ch.Id, unseatedOwner);
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeatLabelIsStoredAndBounded()
|
||||
{
|
||||
@@ -361,6 +397,7 @@ public class ChannelServiceTests : IDisposable
|
||||
await Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload));
|
||||
await Append(ch, o, TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload)); // 乱序到达,缺 1
|
||||
|
||||
Svc.StartItemDownload(ch.Id, o, item, 0);
|
||||
var chunk = await Svc.ReadItemFramesAsync(ch.Id, o, item, 0, 10);
|
||||
Assert.Single(chunk.Frames);
|
||||
Assert.Equal(1u, chunk.NextIndex);
|
||||
@@ -403,7 +440,7 @@ public class ChannelServiceTests : IDisposable
|
||||
Assert.Single((await Svc.ReadItemFramesAsync(ch.Id, ch.Reader(_memberEp), item, 1, 10)).Frames);
|
||||
|
||||
// 宽限期过后拒绝,清理任务删除密文并留下墓碑
|
||||
_env.Clock.Advance(TimeSpan.FromSeconds(_env.Limits.DownloadGraceSeconds + 1));
|
||||
_env.Clock.Advance(TimeSpan.FromSeconds(_env.Limits.DownloadGraceSeconds));
|
||||
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 1));
|
||||
|
||||
Assert.Equal(1, Svc.Sweep());
|
||||
@@ -412,6 +449,29 @@ public class ChannelServiceTests : IDisposable
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.StartItemDownload(ch.Id, o, item, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeNeedsACountedDownloadFromTheSameEndpoint()
|
||||
{
|
||||
var otherEndpoint = TestEnv.Endpoint();
|
||||
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { Seats = 3, FileMaxDownloads = 1 });
|
||||
Svc.Join(ch.Id, ch.Reader(_memberEp));
|
||||
Svc.Join(ch.Id, ch.Reader(otherEndpoint));
|
||||
var item = await UploadGated(ch, ch.Owner(_ownerEp), 2);
|
||||
var otherItem = await UploadGated(ch, ch.Owner(_ownerEp), 2);
|
||||
var reader = ch.Reader(_memberEp);
|
||||
|
||||
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, reader, item, 1));
|
||||
await AssertCode(ErrorCode.Gone, () => Svc.ReadItemFramesAsync(ch.Id, reader, item, 1, 10));
|
||||
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, ch.Reader(otherEndpoint), item, 1));
|
||||
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, reader, otherItem, 1));
|
||||
|
||||
Svc.StartItemDownload(ch.Id, reader, item, 0);
|
||||
Svc.StartItemDownload(ch.Id, reader, item, 1);
|
||||
var resumed = await Svc.ReadItemFramesAsync(ch.Id, reader, item, 1, 10);
|
||||
Assert.Single(resumed.Frames);
|
||||
Assert.Equal(2u, resumed.NextIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ZeroMaxDownloadsMeansUnlimitedAndHeaderOverridesDefault()
|
||||
{
|
||||
@@ -506,9 +566,16 @@ public class ChannelServiceTests : IDisposable
|
||||
await AppendSmall(ch, o, old);
|
||||
|
||||
_env.Clock.Advance(TimeSpan.FromHours(12));
|
||||
await AppendSmall(ch, o); // 续期频道,新条目 TTL 从现在起算
|
||||
var fresh = TestEnv.NewId();
|
||||
await AppendSmall(ch, o, fresh); // 续期频道,新条目 TTL 从现在起算
|
||||
|
||||
_env.Clock.Advance(TimeSpan.FromHours(13)); // 第一个条目已过期,第二个还在
|
||||
var beforeCleanup = await Svc.ReadLogAsync(ch.Id, o, 0, 100);
|
||||
Assert.Single(beforeCleanup.Records);
|
||||
Assert.Equal(fresh.ToArray(), beforeCleanup.Records[0].Bytes[(9 + 2)..(9 + 2 + 16)]);
|
||||
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, o, old, 0));
|
||||
await AssertCode(ErrorCode.Gone, () => Svc.ReadItemFramesAsync(ch.Id, o, old, 0, 10));
|
||||
|
||||
Assert.Equal(1, Svc.Sweep());
|
||||
|
||||
var log = await Svc.ReadLogAsync(ch.Id, o, 0, 100);
|
||||
|
||||
Generated
+25
@@ -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",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@webdrop/core": "file:../packages/core",
|
||||
"cap-widget": "^0.1.58",
|
||||
"idb": "^8.0.3",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
|
||||
+11
-1
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { Home } from '@/features/home/Home';
|
||||
import { JoinScreen, MissingAccess } from '@/features/scan/JoinScreen';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { useShares } from '@/store/shares';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { ChannelScreen } from './ChannelScreen';
|
||||
import { ShareScreen } from './ShareScreen';
|
||||
@@ -21,14 +22,21 @@ function splashSeen(): boolean {
|
||||
export function App() {
|
||||
const route = useRoute();
|
||||
const list = useChannels((s) => s.list);
|
||||
const channelsHydrated = useChannels((s) => s.hydrated);
|
||||
const pendingLink = useChannels((s) => s.pendingLink);
|
||||
const setPendingLink = useChannels((s) => s.setPendingLink);
|
||||
const sharesHydrated = useShares((s) => s.hydrated);
|
||||
const pendingShareLink = useShares((s) => s.pendingLink);
|
||||
const wsReady = useWorkspace((s) => s.ready);
|
||||
const [splash, setSplash] = useState(() => !splashSeen());
|
||||
|
||||
const saved = route.name === 'channel' && list.some((c) => c.id === route.id);
|
||||
// 只有打开已保存的频道才需要等数据;首页与加入页没有加载过程
|
||||
const ready = !saved || wsReady;
|
||||
const waitingOnSavedChannel = route.name === 'channel' && !pendingLink && !channelsHydrated;
|
||||
const waitingOnSavedShare = route.name === 'share' && pendingShareLink?.id !== route.id && !sharesHydrated;
|
||||
const ready = route.name === 'home' || (route.name === 'channel'
|
||||
? pendingLink?.id === route.id || (channelsHydrated && (!saved || wsReady))
|
||||
: pendingShareLink?.id === route.id || sharesHydrated);
|
||||
|
||||
function onSplashDone() {
|
||||
try {
|
||||
@@ -41,7 +49,9 @@ export function App() {
|
||||
|
||||
let screen;
|
||||
if (route.name === 'home') screen = <Home />;
|
||||
else if (route.name === 'share' && waitingOnSavedShare) screen = null;
|
||||
else if (route.name === 'share') screen = <ShareScreen id={route.id} />;
|
||||
else if (route.name === 'channel' && waitingOnSavedChannel) screen = null;
|
||||
else if (saved) screen = <ChannelScreen id={route.id} />;
|
||||
else if (pendingLink?.id === route.id) {
|
||||
screen = (
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -7,9 +7,12 @@ import type { ArchiveEntry, ChannelInfo, ConnectionState, CreateOptions, Workspa
|
||||
*/
|
||||
export interface WorkspaceApi {
|
||||
/** 新建频道:本地生成 id 与密钥、算 PoW、POST channels(api.md) */
|
||||
createChannel(opts: CreateOptions): Promise<ChannelInfo>;
|
||||
createChannel(opts: CreateOptions, captchaToken?: string): Promise<ChannelInfo>;
|
||||
/** 通过链接加入:先 GET 频道信息预览,再 POST seats */
|
||||
joinChannel(link: ParsedChannelLink): Promise<ChannelInfo>;
|
||||
listSaved(): Promise<ChannelInfo[]>;
|
||||
/** 仅从本机删除保存的链接和终端密钥,不会删服务端频道 */
|
||||
forget(id: string): Promise<void>;
|
||||
/** 仅假实现提供:载入演示用的频道 */
|
||||
loadDemo?(): ChannelInfo[];
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
|
||||
|
||||
interface ServerInfo {
|
||||
captcha?: { enabled?: boolean; api_endpoint?: string };
|
||||
}
|
||||
|
||||
/** Cap performs challenge solving in its own workers and only returns a one-use token. */
|
||||
export async function solveCreationCaptcha(): Promise<string | undefined> {
|
||||
const response = await fetch(`${API_BASE.replace(/\/$/, '')}/info`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
referrerPolicy: 'no-referrer',
|
||||
});
|
||||
if (!response.ok) throw new Error('无法读取服务端能力');
|
||||
const info = await response.json() as ServerInfo;
|
||||
if (!info.captcha?.enabled) return undefined;
|
||||
if (typeof info.captcha.api_endpoint !== 'string') throw new Error('验证码服务配置无效');
|
||||
|
||||
// Cap's official client owns the challenge protocol and WASM worker implementation; bundling it avoids a runtime CDN script.
|
||||
const { default: Cap } = await import('cap-widget');
|
||||
const config = { apiEndpoint: info.captcha.api_endpoint, workers: 2 } as ConstructorParameters<typeof Cap>[0] & { workers: number };
|
||||
const { token } = await new Cap(config).solve();
|
||||
if (!token) throw new Error('验证码未完成');
|
||||
return token;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
|
||||
import type { Item } from './types';
|
||||
|
||||
export type ManifestEntry =
|
||||
| { t: 'file'; id: string; name: string; mime: string; size: number; ts: number }
|
||||
| { t: 'text'; localId: string; name?: string; text: string; ts: number };
|
||||
|
||||
export interface SavedResource {
|
||||
id: string;
|
||||
kind: 'channel' | 'share';
|
||||
mode: 'enc' | 'raw';
|
||||
/** Keys stay in IndexedDB. The page URL and localStorage never retain them. */
|
||||
link: string;
|
||||
ownerLink?: string;
|
||||
endpointSecret: Uint8Array;
|
||||
role: 'owner' | 'member';
|
||||
mine?: boolean;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
seats: number;
|
||||
membersCanUpload: boolean;
|
||||
state?: 'draft' | 'sealed';
|
||||
maxVisitors?: number;
|
||||
maxDownloads?: number | null;
|
||||
ttlSeconds?: number;
|
||||
pendingManifestId?: string;
|
||||
localItems?: Item[];
|
||||
draftItems?: Item[];
|
||||
manifestEntries?: ManifestEntry[];
|
||||
}
|
||||
|
||||
export interface QueuedFrame {
|
||||
key: string;
|
||||
resourceId: string;
|
||||
itemId: string;
|
||||
index: number;
|
||||
bytes: Uint8Array;
|
||||
extraHeaders: Record<string, string>;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface DraftFile {
|
||||
key: string;
|
||||
resourceId: string;
|
||||
itemId: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
interface WebDropDb extends DBSchema {
|
||||
resources: { key: string; value: SavedResource };
|
||||
outbox: { key: string; value: QueuedFrame; indexes: { 'by-resource': string } };
|
||||
draftFiles: { key: string; value: DraftFile; indexes: { 'by-resource': string } };
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase<WebDropDb>> | undefined;
|
||||
|
||||
function db(): Promise<IDBPDatabase<WebDropDb>> {
|
||||
dbPromise ??= openDB<WebDropDb>('webdrop-local', 2, {
|
||||
upgrade(database, oldVersion) {
|
||||
if (oldVersion < 1) {
|
||||
database.createObjectStore('resources', { keyPath: 'id' });
|
||||
const outbox = database.createObjectStore('outbox', { keyPath: 'key' });
|
||||
outbox.createIndex('by-resource', 'resourceId');
|
||||
}
|
||||
if (oldVersion < 2) {
|
||||
const draftFiles = database.createObjectStore('draftFiles', { keyPath: 'key' });
|
||||
draftFiles.createIndex('by-resource', 'resourceId');
|
||||
}
|
||||
},
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
export async function saveResource(resource: SavedResource): Promise<void> {
|
||||
await (await db()).put('resources', resource);
|
||||
}
|
||||
|
||||
export async function getResource(id: string): Promise<SavedResource | undefined> {
|
||||
return (await db()).get('resources', id);
|
||||
}
|
||||
|
||||
export async function listResources(kind: SavedResource['kind']): Promise<SavedResource[]> {
|
||||
const values = await (await db()).getAll('resources');
|
||||
return values.filter((resource) => resource.kind === kind).sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
export async function removeResource(id: string): Promise<void> {
|
||||
const database = await db();
|
||||
const tx = database.transaction(['resources', 'outbox', 'draftFiles'], 'readwrite');
|
||||
await tx.objectStore('resources').delete(id);
|
||||
const keys = await tx.objectStore('outbox').index('by-resource').getAllKeys(id);
|
||||
await Promise.all(keys.map((key) => tx.objectStore('outbox').delete(key)));
|
||||
const fileKeys = await tx.objectStore('draftFiles').index('by-resource').getAllKeys(id);
|
||||
await Promise.all(fileKeys.map((key) => tx.objectStore('draftFiles').delete(key)));
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
export async function saveDraftFile(resourceId: string, itemId: string, file: File): Promise<void> {
|
||||
const key = `${resourceId}:${itemId}`;
|
||||
await (await db()).put('draftFiles', { key, resourceId, itemId, file });
|
||||
}
|
||||
|
||||
export async function getDraftFile(resourceId: string, itemId: string): Promise<File | undefined> {
|
||||
return (await db()).get('draftFiles', `${resourceId}:${itemId}`).then((record) => record?.file);
|
||||
}
|
||||
|
||||
export async function removeDraftFile(resourceId: string, itemId: string): Promise<void> {
|
||||
await (await db()).delete('draftFiles', `${resourceId}:${itemId}`);
|
||||
}
|
||||
|
||||
export async function clearDraftFiles(resourceId: string): Promise<void> {
|
||||
const database = await db();
|
||||
const tx = database.transaction('draftFiles', 'readwrite');
|
||||
const keys = await tx.store.index('by-resource').getAllKeys(resourceId);
|
||||
await Promise.all(keys.map((key) => tx.store.delete(key)));
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
export async function queueFrame(frame: QueuedFrame): Promise<void> {
|
||||
await (await db()).put('outbox', frame);
|
||||
}
|
||||
|
||||
export async function queuedFrames(resourceId: string): Promise<QueuedFrame[]> {
|
||||
const frames = await (await db()).getAllFromIndex('outbox', 'by-resource', resourceId);
|
||||
return frames.sort((a, b) => a.createdAt - b.createdAt || a.itemId.localeCompare(b.itemId) || a.index - b.index);
|
||||
}
|
||||
|
||||
export async function dequeueFrame(key: string): Promise<void> {
|
||||
await (await db()).delete('outbox', key);
|
||||
}
|
||||
|
||||
export async function nextQueuedFrame(resourceId: string): Promise<QueuedFrame | undefined> {
|
||||
const values = await (await db()).getAllFromIndex('outbox', 'by-resource', resourceId, 1);
|
||||
return values[0];
|
||||
}
|
||||
|
||||
export async function dequeueItem(resourceId: string, itemId: string): Promise<void> {
|
||||
const database = await db();
|
||||
const tx = database.transaction('outbox', 'readwrite');
|
||||
const frames = await tx.store.index('by-resource').getAll(resourceId);
|
||||
await Promise.all(frames.filter((frame) => frame.itemId === itemId).map((frame) => tx.store.delete(frame.key)));
|
||||
await tx.done;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
const messages: Record<string, string> = {
|
||||
not_found: '内容不存在或已过期。', gone: '内容已删除、下载次数已用完或续传已失效。',
|
||||
seats_full: '频道席位已满,请联系所有者。', not_joined: '你已被移出此频道。',
|
||||
visitors_full: '分享的访客名额已满。', unauthorized: '链接权限无效,请使用完整链接重新打开。',
|
||||
forbidden: '此链接没有执行此操作的权限。', sealed: '分享已封存,无法再修改。',
|
||||
too_large: '内容超过服务端容量或条目数量限制。', rate_limited: '请求过于频繁,请稍后重试。',
|
||||
captcha_required: '验证码未通过或已失效,请重新创建。',
|
||||
captcha_unavailable: '验证码服务暂时不可用,请稍后重试。',
|
||||
api_contract_mismatch: '服务端 API 有破坏性更新,请刷新或更新前端后再试。',
|
||||
mode_mismatch: '频道加密模式与链接不符,已拒绝访问。', conflict: '上传内容发生冲突,已停止发送。',
|
||||
};
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (!(error instanceof Error)) return '操作失败,请重试。';
|
||||
const mapped = messages[error.message];
|
||||
if (mapped) return mapped;
|
||||
// 不回显来自服务端或解析器的原始错误,它们可能包含密钥或明文。
|
||||
return /[\u4e00-\u9fff]/.test(error.message) && error.message.length < 100
|
||||
? error.message : '操作失败,请重试。';
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
import type { ParsedShareLink } from '@/lib/shareLink';
|
||||
import type { WorkspaceApi } from './api';
|
||||
import type { ShareApi } from './shareApi';
|
||||
import { callWorker, subscribeWorker } from './workerClient';
|
||||
import type { ArchiveEntry, ChannelInfo, ConnectionState, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
|
||||
|
||||
function reportChannelUnavailable(id: string, onEvent: (event: WorkspaceEvent) => void): void {
|
||||
onEvent({ t: 'connection', state: 'offline' });
|
||||
onEvent({ t: 'ready' });
|
||||
void id;
|
||||
}
|
||||
|
||||
export const realApi: WorkspaceApi = {
|
||||
createChannel: (options: CreateOptions, captchaToken?: string) => callWorker<ChannelInfo>('createChannel', { options, captchaToken, apiBase: API_BASE }),
|
||||
joinChannel: (link: ParsedChannelLink) => callWorker<ChannelInfo>('joinChannel', { link, apiBase: API_BASE }),
|
||||
listSaved: () => callWorker<ChannelInfo[]>('listChannels', { apiBase: API_BASE }),
|
||||
forget: (id) => callWorker<void>('forgetChannel', { id, apiBase: API_BASE }),
|
||||
|
||||
connect(channelId, onEvent) {
|
||||
activeChannelId = channelId;
|
||||
activeShareId = '';
|
||||
const unsubscribe = subscribeWorker<WorkspaceEvent>(channelId, onEvent);
|
||||
void callWorker<boolean>('connectChannel', { id: channelId, apiBase: API_BASE }).catch(() => reportChannelUnavailable(channelId, onEvent));
|
||||
return () => {
|
||||
unsubscribe();
|
||||
void callWorker('stopChannel', { id: channelId, apiBase: API_BASE });
|
||||
};
|
||||
},
|
||||
|
||||
sendText: (text) => callWorker<void>('sendText', { id: currentChannelId(), text, apiBase: API_BASE }),
|
||||
sendFiles: (files) => callWorker<void>('sendFiles', { id: currentChannelId(), files, apiBase: API_BASE }),
|
||||
editText: (id, text) => callWorker<void>('editChannelText', { id: currentChannelId(), itemId: id, text, apiBase: API_BASE }),
|
||||
resolveConflict: (id, choice) => callWorker<void>('resolveTextConflict', { id: currentChannelId(), itemId: id, choice, apiBase: API_BASE }),
|
||||
remove: (id) => callWorker<void>('removeChannelItem', { id: currentChannelId(), itemId: id, apiBase: API_BASE }),
|
||||
setSnapshot: (text) => callWorker<void>('updateSnapshot', { id: currentChannelId(), text, apiBase: API_BASE }),
|
||||
getBlob: (id, versionId) => callWorker<Blob>('getChannelBlob', { id: currentChannelId(), itemId: id, versionId, apiBase: API_BASE }),
|
||||
listArchive: (_id: string): Promise<ArchiveEntry[]> => callWorker<ArchiveEntry[]>('listArchive', { id: currentChannelId(), apiBase: API_BASE }),
|
||||
markSeen: (id) => callWorker<void>('markChannelSeen', { id: currentChannelId(), itemId: id, apiBase: API_BASE }),
|
||||
};
|
||||
|
||||
export const realShareApi: ShareApi = {
|
||||
createDraft: (settings: ShareSettings, captchaToken?: string) => callWorker<Share>('createShare', { settings, captchaToken, apiBase: API_BASE }),
|
||||
listSaved: () => callWorker<Share[]>('listShares', { apiBase: API_BASE }),
|
||||
updateSettings: (id, patch) => callWorker<Share>('updateShareSettings', { id, patch, apiBase: API_BASE }),
|
||||
seal: (id) => callWorker<Share>('sealShare', { id, apiBase: API_BASE }),
|
||||
join: (link: ParsedShareLink) => callWorker<Share>('joinShare', { link, apiBase: API_BASE }),
|
||||
forget: (id) => callWorker<void>('forgetShare', { id, apiBase: API_BASE }),
|
||||
|
||||
connect(shareId, onEvent) {
|
||||
activeShareId = shareId;
|
||||
activeChannelId = '';
|
||||
const unsubscribe = subscribeWorker<ShareEvent>(shareId, onEvent);
|
||||
void callWorker<void>('connectShare', { id: shareId, apiBase: API_BASE }).catch((error: unknown) => {
|
||||
onEvent({ t: 'error', message: error instanceof Error ? error.message : '无法打开分享' });
|
||||
onEvent({ t: 'ready' });
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
void callWorker('stopShare', { id: shareId, apiBase: API_BASE });
|
||||
};
|
||||
},
|
||||
|
||||
addText: (text) => callWorker<void>('addShareText', { id: currentShareId(), text, apiBase: API_BASE }),
|
||||
editText: (id, text) => callWorker<void>('editShareText', { id: currentShareId(), itemId: id, text, apiBase: API_BASE }),
|
||||
addFiles: (files) => callWorker<void>('addShareFiles', { id: currentShareId(), files, apiBase: API_BASE }),
|
||||
removeItem: (id) => callWorker<void>('removeShareItem', { id: currentShareId(), itemId: id, apiBase: API_BASE }),
|
||||
getBlob: (id) => callWorker<Blob>('getShareBlob', { id: currentShareId(), itemId: id, apiBase: API_BASE }),
|
||||
listArchive: (_id: string): Promise<ArchiveEntry[]> => callWorker<ArchiveEntry[]>('listArchive', { id: currentShareId(), apiBase: API_BASE }),
|
||||
};
|
||||
|
||||
let activeChannelId = '';
|
||||
let activeShareId = '';
|
||||
|
||||
function currentChannelId(): string {
|
||||
return activeChannelId;
|
||||
}
|
||||
|
||||
function currentShareId(): string {
|
||||
return activeShareId;
|
||||
}
|
||||
|
||||
export function simulateConnection(_state: ConnectionState): void {}
|
||||
@@ -6,8 +6,9 @@ import type { ArchiveEntry, Share, ShareEvent, ShareSettings } from './types';
|
||||
* 「草稿 → 封存 → 过期」。线路上仍是 channels(成员不可上传),所以由同一个 core 会话实现。
|
||||
*/
|
||||
export interface ShareApi {
|
||||
listSaved(): Promise<Share[]>;
|
||||
/** 新建草稿:创建者握着所有者密钥。此时还没有可分享的链接 */
|
||||
createDraft(settings: ShareSettings): Promise<Share>;
|
||||
createDraft(settings: ShareSettings, captchaToken?: string): Promise<Share>;
|
||||
/** 草稿阶段调整设置(PATCH settings,需所有者令牌) */
|
||||
updateSettings(id: string, patch: Partial<ShareSettings>): Promise<Share>;
|
||||
/**
|
||||
@@ -17,6 +18,8 @@ export interface ShareApi {
|
||||
seal(id: string): Promise<Share>;
|
||||
/** 访客通过链接直接加入(不预览、不展示人数),返回这个分享 */
|
||||
join(link: ParsedShareLink): Promise<Share>;
|
||||
/** 仅从本机移除链接和终端密钥,不会删除已封存的远端分享 */
|
||||
forget(id: string): Promise<void>;
|
||||
/** 仅假实现提供:载入演示用的分享 */
|
||||
loadDemo?(): Share[];
|
||||
|
||||
@@ -24,6 +27,7 @@ export interface ShareApi {
|
||||
connect(shareId: string, onEvent: (e: ShareEvent) => void): () => void;
|
||||
/** 草稿阶段添加与移除内容 */
|
||||
addText(text: string): Promise<void>;
|
||||
editText(id: string, text: string): Promise<void>;
|
||||
addFiles(files: File[]): Promise<void>;
|
||||
removeItem(id: string): Promise<void>;
|
||||
/** 取内容(封存后每次拉取消耗一次下载,见 api.md) */
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,74 @@
|
||||
import { setApiBase } from './worker/http';
|
||||
import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems';
|
||||
import { createChannel, forgetChannel, joinChannel, listChannels } from './worker/channelResources';
|
||||
import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend';
|
||||
import { stopChannel } from './worker/channelTransport';
|
||||
import { addShareFiles, addShareText, editShareText, removeShareItem } from './worker/shareDraft';
|
||||
import { connectShare, getShareBlob, stopShare } from './worker/shareRead';
|
||||
import { createShare, joinShare, listSavedShares, removeShare, updateShareSettings } from './worker/shareResources';
|
||||
import { sealShare } from './worker/shareSeal';
|
||||
import { setEventSink } from './worker/port';
|
||||
import { serial } from './worker/serial';
|
||||
import { errorMessage } from './errors';
|
||||
|
||||
interface WorkerRequest { requestId: number; operation: string; payload: unknown }
|
||||
interface WorkerScope {
|
||||
onmessage: ((event: MessageEvent<WorkerRequest>) => void) | null;
|
||||
postMessage(message: unknown, transfer?: Transferable[]): void;
|
||||
}
|
||||
|
||||
const scope = self as unknown as WorkerScope;
|
||||
setEventSink((resourceId, event) => scope.postMessage({ resourceId, event }));
|
||||
|
||||
async function dispatch(operation: string, input: unknown): Promise<unknown> {
|
||||
const payload = input as Record<string, unknown>;
|
||||
if (typeof payload.apiBase === 'string') setApiBase(payload.apiBase);
|
||||
const id = typeof payload.id === 'string' ? payload.id : '';
|
||||
switch (operation) {
|
||||
case 'createChannel': return createChannel(payload.options as Parameters<typeof createChannel>[0], typeof payload.captchaToken === 'string' ? payload.captchaToken : undefined);
|
||||
case 'joinChannel': return joinChannel(payload as Parameters<typeof joinChannel>[0]);
|
||||
case 'listChannels': return listChannels();
|
||||
case 'forgetChannel': return forgetChannel(id);
|
||||
case 'connectChannel': void connectChannelResource(id); return true;
|
||||
case 'stopChannel': stopChannel(id); return true;
|
||||
case 'sendText': return sendText(id, String(payload.text ?? ''));
|
||||
case 'sendFiles': return sendFiles(id, payload.files as File[]);
|
||||
case 'removeChannelItem': return removeChannelItem(id, String(payload.itemId));
|
||||
case 'getChannelBlob': return getChannelBlob(id, String(payload.itemId), typeof payload.versionId === 'string' ? payload.versionId : undefined);
|
||||
case 'updateSnapshot': return updateSnapshot(id, String(payload.text ?? ''));
|
||||
case 'editChannelText': return editChannelText(id, String(payload.itemId), String(payload.text ?? ''));
|
||||
case 'resolveTextConflict': return resolveTextConflict();
|
||||
case 'markChannelSeen': return markChannelSeen();
|
||||
case 'listArchive': return listArchive(id);
|
||||
case 'createShare': return createShare(payload.settings as Parameters<typeof createShare>[0], typeof payload.captchaToken === 'string' ? payload.captchaToken : undefined);
|
||||
case 'joinShare': return joinShare(payload as Parameters<typeof joinShare>[0]);
|
||||
case 'listShares': return listSavedShares();
|
||||
case 'updateShareSettings': return updateShareSettings(id, payload.patch as Parameters<typeof updateShareSettings>[1]);
|
||||
case 'sealShare': return sealShare(id);
|
||||
case 'connectShare': return connectShare(id);
|
||||
case 'stopShare': stopShare(id); return true;
|
||||
case 'addShareText': return addShareText(id, String(payload.text ?? ''));
|
||||
case 'editShareText': return editShareText(id, String(payload.itemId), String(payload.text ?? ''));
|
||||
case 'addShareFiles': return addShareFiles(id, payload.files as File[]);
|
||||
case 'removeShareItem': return removeShareItem(id, String(payload.itemId));
|
||||
case 'getShareBlob': return getShareBlob(id, String(payload.itemId));
|
||||
case 'forgetShare': return removeShare(id);
|
||||
default: throw new Error('未知的前端操作');
|
||||
}
|
||||
}
|
||||
|
||||
scope.onmessage = (event) => {
|
||||
const request = event.data;
|
||||
const payload = request.payload as { id?: string; link?: { id: string } };
|
||||
const key = payload.id ?? payload.link?.id;
|
||||
const readOnly = /^(connect|stop|list|get)/.test(request.operation);
|
||||
const task = key && !readOnly ? serial(key, () => dispatch(request.operation, request.payload))
|
||||
: dispatch(request.operation, request.payload);
|
||||
void task.then(
|
||||
(result) => scope.postMessage({ requestId: request.requestId, result }),
|
||||
(error: unknown) => scope.postMessage({
|
||||
requestId: request.requestId,
|
||||
error: errorMessage(error),
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { parseLogRecords, toBase64Url, type ChannelSession, type LogRecord, type ReceivedItem } from '@webdrop/core';
|
||||
import { getResource, saveResource, type SavedResource } from '../database';
|
||||
import type { FileItem, Item, Snapshot } from '../types';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import { blobs, readers } from './sessions';
|
||||
import { showRejected, deviceIdentity, deviceInfo, publishWorkspace } from './presentation';
|
||||
import { type ChannelWire, toChannelInfo } from './wire';
|
||||
import { apiFetch, readJson } from './http';
|
||||
|
||||
export function mapReceived(item: ReceivedItem): Item {
|
||||
const timestamp = item.meta?.ts ?? Date.now();
|
||||
const deviceId = item.meta?.dev ?? '未知设备';
|
||||
const state = item.complete ? { kind: 'complete' as const } : { kind: 'incomplete' as const };
|
||||
if (item.kind === 'file' || item.kind === 'raw') {
|
||||
const name = item.meta?.name ?? '加密文件';
|
||||
return {
|
||||
id: item.itemId, kind: 'file', ts: timestamp, deviceId, state, name,
|
||||
mime: item.meta?.mime ?? 'application/octet-stream', size: item.meta?.size ?? item.content.byteLength,
|
||||
remaining: null, preview: previewKindOf(item.meta?.mime ?? '', name),
|
||||
gated: item.gated,
|
||||
};
|
||||
}
|
||||
return { id: item.itemId, kind: 'text', ts: timestamp, deviceId, state, text: new TextDecoder().decode(item.content) };
|
||||
}
|
||||
|
||||
export async function applyChannelRecord(resource: SavedResource, session: ChannelSession, input: Uint8Array | LogRecord): Promise<void> {
|
||||
let reader = readers.get(resource.id);
|
||||
if (!reader) {
|
||||
reader = session.createItemReader();
|
||||
readers.set(resource.id, reader);
|
||||
}
|
||||
const events = await reader.push(input);
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
case 'item': {
|
||||
const item = mapReceived(event.item);
|
||||
if (event.item.snapshot) {
|
||||
let snapshot: Snapshot = { text: '', updatedBy: item.deviceId, updatedAt: item.ts };
|
||||
if (item.kind === 'text') {
|
||||
try {
|
||||
const value = JSON.parse(item.text) as Partial<Snapshot>;
|
||||
if (typeof value.text === 'string') snapshot = { text: value.text, updatedBy: value.updatedBy ?? item.deviceId, updatedAt: value.updatedAt ?? item.ts };
|
||||
else snapshot.text = item.text;
|
||||
} catch { snapshot.text = item.text; }
|
||||
}
|
||||
publishWorkspace(resource.id, { t: 'snapshot', snapshot });
|
||||
} else {
|
||||
const blob = item.kind === 'file' && event.item.complete ? new Blob([event.item.content], { type: item.mime }) : undefined;
|
||||
if (blob) blobs.set(`${resource.id}:${item.id}`, blob);
|
||||
publishWorkspace(resource.id, { t: 'item', item });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'announce': {
|
||||
const local = (await getResource(resource.id))?.localItems?.find((item) => item.id === event.itemId);
|
||||
const placeholder: FileItem = local?.kind === 'file' ? local : {
|
||||
id: event.itemId, kind: 'file', ts: event.createdAt, deviceId: '未知设备', state: { kind: 'incomplete' },
|
||||
name: '受限下载内容', mime: 'application/octet-stream', size: 0, remaining: null, preview: 'none',
|
||||
gated: true,
|
||||
};
|
||||
publishWorkspace(resource.id, { t: 'item', item: placeholder });
|
||||
break;
|
||||
}
|
||||
case 'removed':
|
||||
blobs.delete(`${resource.id}:${event.itemId}`);
|
||||
publishWorkspace(resource.id, { t: 'removed', id: event.itemId });
|
||||
break;
|
||||
case 'rejected':
|
||||
showRejected(resource.id, event);
|
||||
break;
|
||||
default: {
|
||||
const exhaustive: never = event;
|
||||
throw new Error(`未知的加密事件 ${String(exhaustive)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function channelMetadata(resource: SavedResource, session: ChannelSession): Promise<void> {
|
||||
const info = await readJson<ChannelWire>(`/channels/${resource.id}`, session.authHeaders('read'));
|
||||
session.verifyChannelInfo(info);
|
||||
resource.membersCanUpload = info.settings.members_can_upload;
|
||||
resource.expiresAt = info.expires_at;
|
||||
const latest = await getResource(resource.id);
|
||||
await saveResource({ ...latest, ...resource, localItems: latest?.localItems });
|
||||
publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) });
|
||||
const ownId = toBase64Url(session.endpointHash);
|
||||
let devices = [deviceInfo(ownId, Date.now(), deviceIdentity().name, ownId)];
|
||||
if (session.can('owner')) {
|
||||
const seats = await readJson<Array<{ endpoint_hash: string; joined_at: number; label?: string }>>(
|
||||
`/channels/${resource.id}/seats`, session.authHeaders('owner'));
|
||||
devices = await Promise.all(seats.map(async (seat) => {
|
||||
let name: string | undefined;
|
||||
if (seat.label) {
|
||||
try { name = await session.openLabel(seat.label, seat.endpoint_hash); }
|
||||
catch { name = '设备名校验失败'; }
|
||||
}
|
||||
return deviceInfo(seat.endpoint_hash, seat.joined_at, name, ownId);
|
||||
}));
|
||||
const own = devices.find((device) => device.self);
|
||||
if (own) own.name = deviceIdentity().name;
|
||||
}
|
||||
publishWorkspace(resource.id, { t: 'devices', devices });
|
||||
}
|
||||
|
||||
export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise<number> {
|
||||
publishWorkspace(resource.id, { t: 'reset' });
|
||||
readers.set(resource.id, session.createItemReader());
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const response = await apiFetch(`/channels/${resource.id}/log?from=${from}&limit=200`, {
|
||||
headers: session.authHeaders('read'), signal,
|
||||
});
|
||||
const records = parseLogRecords(new Uint8Array(await response.arrayBuffer()));
|
||||
for (const record of records) {
|
||||
await applyChannelRecord(resource, session, record);
|
||||
from = Math.max(from, record.seq + 1);
|
||||
}
|
||||
if (records.length < 200) return from;
|
||||
}
|
||||
}
|
||||
|
||||
export async function processChannelBytes(resource: SavedResource, session: ChannelSession, bytes: Uint8Array): Promise<number> {
|
||||
const records = parseLogRecords(bytes);
|
||||
let next = 0;
|
||||
for (const record of records) {
|
||||
await applyChannelRecord(resource, session, record);
|
||||
next = Math.max(next, record.seq + 1);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import type { FileItem } from '../types';
|
||||
import { apiFetch } from './http';
|
||||
import { blobs, getSession } from './sessions';
|
||||
|
||||
|
||||
|
||||
import { stopChannel, connectChannel } from './channelTransport';
|
||||
import { downloadDecrypted } from './transfer';
|
||||
import { publishWorkspace } from './presentation';
|
||||
import { IntegrityError } from '@webdrop/core';
|
||||
|
||||
export function connectChannelResource(id: string): () => void {
|
||||
void connectChannel(id);
|
||||
return () => stopChannel(id);
|
||||
}
|
||||
|
||||
export async function removeChannelItem(resourceId: string, itemId: string): Promise<void> {
|
||||
const { session } = await getSession(resourceId);
|
||||
const role = session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read';
|
||||
await apiFetch(`/channels/${resourceId}/items/${itemId}`, {
|
||||
method: 'DELETE', headers: session.authHeaders(role),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChannelBlob(resourceId: string, itemId: string, versionId?: string): Promise<Blob> {
|
||||
if (versionId) throw new Error('此版本暂不可下载');
|
||||
const cached = blobs.get(`${resourceId}:${itemId}`);
|
||||
if (cached) return cached;
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
|
||||
let result: Awaited<ReturnType<typeof downloadDecrypted>>;
|
||||
try { result = await downloadDecrypted(session, resource, itemId); }
|
||||
catch (error) {
|
||||
if (error instanceof IntegrityError) publishWorkspace(resourceId, { t: 'item', item: { id: itemId, kind: 'text', ts: Date.now(), deviceId: '', text: '', state: { kind: 'tampered' } } });
|
||||
throw error;
|
||||
}
|
||||
blobs.set(`${resourceId}:${itemId}`, result.blob);
|
||||
if (result.meta?.kind === 'file') {
|
||||
const item: FileItem = {
|
||||
id: itemId, kind: 'file', ts: result.meta.ts, deviceId: result.meta.dev ?? '未知设备', state: { kind: 'complete' },
|
||||
name: result.meta.name ?? '加密文件', mime: result.meta.mime ?? result.blob.type, size: result.blob.size,
|
||||
remaining: null, preview: 'none', gated: true,
|
||||
};
|
||||
item.preview = previewKindOf(item.mime, item.name);
|
||||
publishWorkspace(resourceId, { t: 'item', item });
|
||||
}
|
||||
return result.blob;
|
||||
}
|
||||
|
||||
export async function resolveTextConflict(): Promise<void> {
|
||||
throw new Error('此条目没有可合并的冲突版本');
|
||||
}
|
||||
|
||||
export async function markChannelSeen(): Promise<void> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function listArchive(_id: string): Promise<never[]> {
|
||||
throw new Error('服务端没有提供压缩包目录接口,可以直接下载。');
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ChannelSession } from '@webdrop/core';
|
||||
import { getResource, listResources, removeResource, saveResource, type SavedResource } from '../database';
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
|
||||
import type { ChannelInfo, CreateOptions } from '../types';
|
||||
import { apiFetch, readJson } from './http';
|
||||
import { sessions } from './sessions';
|
||||
import { deviceIdentity } from './presentation';
|
||||
import { makeLink } from './links';
|
||||
import { toChannelInfo, type ChannelWire } from './wire';
|
||||
import { stopChannel } from './channelTransport';
|
||||
|
||||
|
||||
|
||||
export async function createChannel(options: CreateOptions, captchaToken?: string): Promise<ChannelInfo> {
|
||||
const endpointSecret = crypto.getRandomValues(new Uint8Array(32));
|
||||
const { session, request } = await ChannelSession.create({
|
||||
mode: options.mode,
|
||||
endpointSecret,
|
||||
settings: {
|
||||
seats: options.seats,
|
||||
ttl_seconds: options.ttlSeconds,
|
||||
members_can_upload: options.membersCanUpload,
|
||||
delete_policy: 'own',
|
||||
max_items: 1000,
|
||||
file_max_downloads: 10,
|
||||
ui_restrict_copy_download: false,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const info = await apiFetch('/channels', {
|
||||
method: 'POST', headers: { ...request.headers, 'Content-Type': 'application/json', ...(captchaToken ? { 'X-WD-Cap': captchaToken } : {}) }, body: JSON.stringify(request.body),
|
||||
}).then((response) => response.json() as Promise<ChannelWire>);
|
||||
session.verifyChannelInfo(info);
|
||||
const resource: SavedResource = {
|
||||
id: session.id,
|
||||
kind: 'channel', mode: session.mode, role: 'owner',
|
||||
link: session.link(self.location.origin), ownerLink: session.link(self.location.origin, 'owner'),
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
|
||||
seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload,
|
||||
maxDownloads: info.settings.file_max_downloads, localItems: [],
|
||||
};
|
||||
await saveResource(resource);
|
||||
sessions.set(session.id, session);
|
||||
return toChannelInfo(info, resource);
|
||||
} catch (error) {
|
||||
session.dispose();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function joinChannel(input: { link: ParsedChannelLink }): Promise<ChannelInfo> {
|
||||
const { id, keys } = input.link;
|
||||
const existing = await getResource(id);
|
||||
const endpointSecret = existing?.endpointSecret ?? crypto.getRandomValues(new Uint8Array(32));
|
||||
const session = await ChannelSession.open(makeLink(self.location.origin, id, keys), { endpointSecret });
|
||||
try {
|
||||
const info = await readJson<ChannelWire>(`/channels/${id}`, session.authHeaders('read'));
|
||||
session.verifyChannelInfo(info);
|
||||
await apiFetch(`/channels/${id}/seats`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read') });
|
||||
const resource: SavedResource = {
|
||||
id, kind: 'channel', mode: session.mode, role: session.role,
|
||||
link: session.link(self.location.origin), ownerLink: session.can('owner') ? session.link(self.location.origin, 'owner') : undefined,
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
|
||||
seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload,
|
||||
maxDownloads: info.settings.file_max_downloads, localItems: existing?.localItems ?? [],
|
||||
};
|
||||
const label = await session.sealLabel(deviceIdentity().name);
|
||||
await apiFetch(`/channels/${id}/seats/me`, {
|
||||
method: 'PUT', headers: { ...session.authHeaders('read'), 'Content-Type': 'application/octet-stream' }, body: label,
|
||||
});
|
||||
await saveResource(resource);
|
||||
sessions.set(id, session);
|
||||
return toChannelInfo(info, resource);
|
||||
} catch (error) {
|
||||
session.dispose();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listChannels(): Promise<ChannelInfo[]> {
|
||||
const saved = await listResources('channel');
|
||||
return saved.map((resource) => ({
|
||||
id: resource.id, mode: resource.mode, role: resource.role, seats: resource.seats,
|
||||
memberLink: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function forgetChannel(id: string): Promise<void> {
|
||||
stopChannel(id);
|
||||
sessions.get(id)?.dispose();
|
||||
sessions.delete(id);
|
||||
await removeResource(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { removeChannelItem } from './channelItems';
|
||||
|
||||
import { saveResource } from '../database';
|
||||
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import type { FileItem, Item } from '../types';
|
||||
|
||||
import { blobs, getSession } from './sessions';
|
||||
import { deviceIdentity } from './presentation';
|
||||
|
||||
|
||||
|
||||
import { flushOutbox, persistPlan } from './transfer';
|
||||
import { publishWorkspace } from './presentation';
|
||||
|
||||
export async function sendText(resourceId: string, text: string): Promise<{ itemId: string; uploaded: boolean }> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限');
|
||||
const device = deviceIdentity();
|
||||
session.deviceName = device.name;
|
||||
const ts = Date.now();
|
||||
const plan = session.encryptText(text, { dev: device.name, ts });
|
||||
const item: Item = { id: plan.itemId, kind: 'text', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0 }, text };
|
||||
blobs.set(`${resourceId}:${item.id}`, new Blob([text], { type: 'text/plain' }));
|
||||
publishWorkspace(resourceId, { t: 'item', item });
|
||||
await persistPlan(resource, plan);
|
||||
const uploaded = await flushOutbox(resource, session);
|
||||
publishWorkspace(resourceId, { t: 'item', item: { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } } });
|
||||
if (!uploaded) publishWorkspace(resourceId, { t: 'connection', state: 'offline' });
|
||||
return { itemId: plan.itemId, uploaded };
|
||||
}
|
||||
|
||||
export async function sendFiles(resourceId: string, files: File[]): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限');
|
||||
const device = deviceIdentity();
|
||||
session.deviceName = device.name;
|
||||
for (const file of files) {
|
||||
const ts = Date.now();
|
||||
const plan = session.encryptFile(file, {}, { maxDownloads: resource.maxDownloads ?? 10, dev: device.name, ts });
|
||||
let item: FileItem = {
|
||||
id: plan.itemId, kind: 'file', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0 },
|
||||
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
|
||||
remaining: resource.maxDownloads || null, preview: previewKindOf(file.type, file.name), gated: true,
|
||||
};
|
||||
resource.localItems = [...(resource.localItems ?? []).filter((entry) => entry.id !== item.id), item];
|
||||
await saveResource(resource);
|
||||
blobs.set(`${resourceId}:${item.id}`, file);
|
||||
publishWorkspace(resourceId, { t: 'item', item });
|
||||
await persistPlan(resource, plan);
|
||||
const uploaded = await flushOutbox(resource, session);
|
||||
item = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } };
|
||||
resource.localItems = resource.localItems.map((entry) => entry.id === item.id ? item : entry);
|
||||
await saveResource(resource);
|
||||
publishWorkspace(resourceId, { t: 'item', item });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSnapshot(resourceId: string, text: string): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
const plan = session.encryptJson({ text, updatedBy: deviceIdentity().name, updatedAt: Date.now() }, { snapshot: true, dev: deviceIdentity().name });
|
||||
await persistPlan(resource, plan);
|
||||
await flushOutbox(resource, session);
|
||||
}
|
||||
|
||||
export async function editChannelText(resourceId: string, itemId: string, text: string): Promise<void> {
|
||||
const created = await sendText(resourceId, text);
|
||||
if (!created.uploaded) throw new Error('新版本已排队,网络恢复前保留旧内容。');
|
||||
await removeChannelItem(resourceId, itemId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { IntegrityError } from '@webdrop/core';
|
||||
import { active, getSession } from './sessions';
|
||||
import { apiFetch, type ApiError } from './http';
|
||||
import { publishWorkspace } from './presentation';
|
||||
import { channelMetadata, processChannelBytes, syncChannel } from './channelEvents';
|
||||
import { flushOutbox } from './transfer';
|
||||
import { socketStream, sseStream, delay, type StreamConsumer } from './transports';
|
||||
import { errorMessage } from '../errors';
|
||||
|
||||
export async function connectChannel(id: string): Promise<void> {
|
||||
active.get(id)?.abort();
|
||||
const controller = new AbortController();
|
||||
active.set(id, controller);
|
||||
const signal = controller.signal;
|
||||
let flushTimer: ReturnType<typeof setInterval> | undefined;
|
||||
try {
|
||||
const { session, resource } = await getSession(id);
|
||||
if (signal.aborted) return;
|
||||
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
|
||||
const stream: StreamConsumer = {
|
||||
cursor: 0,
|
||||
async record(bytes) { stream.cursor = Math.max(stream.cursor, await processChannelBytes(resource, session, bytes)); },
|
||||
async control(value) { if (value.t === 'settings' || value.t === 'seats') await channelMetadata(resource, session); },
|
||||
online() { if (!signal.aborted) publishWorkspace(id, { t: 'connection', state: 'online' }); },
|
||||
};
|
||||
let initialized = false;
|
||||
let transport = 0;
|
||||
let backoff = 1000;
|
||||
flushTimer = setInterval(() => {
|
||||
if (!signal.aborted) void flushOutbox(resource, session).catch((error: unknown) => {
|
||||
publishWorkspace(id, { t: 'error', message: errorMessage(error) });
|
||||
});
|
||||
}, 2500);
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
if (!initialized) {
|
||||
stream.cursor = await syncChannel(resource, session, signal);
|
||||
if (signal.aborted) break;
|
||||
await channelMetadata(resource, session);
|
||||
await flushOutbox(resource, session);
|
||||
initialized = true;
|
||||
publishWorkspace(id, { t: 'ready' });
|
||||
}
|
||||
stream.online();
|
||||
if (transport === 0) await socketStream(id, session, signal, stream);
|
||||
else if (transport === 1) await sseStream(id, session, signal, stream);
|
||||
else {
|
||||
const response = await apiFetch(`/channels/${id}/log?from=${stream.cursor}&limit=200&wait=25`, { headers: session.authHeaders('read'), signal });
|
||||
await stream.record(new Uint8Array(await response.arrayBuffer()));
|
||||
}
|
||||
backoff = 1000;
|
||||
if (transport < 2) transport++;
|
||||
} catch (error) {
|
||||
if (signal.aborted) break;
|
||||
const code = (error as ApiError).status;
|
||||
if (code === 410) { initialized = false; continue; }
|
||||
if (code === 401 || code === 403 || code === 404 || error instanceof IntegrityError) throw error;
|
||||
if (transport < 2 && initialized) { transport++; continue; }
|
||||
publishWorkspace(id, { t: 'connection', state: 'offline' });
|
||||
publishWorkspace(id, { t: 'ready' });
|
||||
await delay(backoff + Math.random() * backoff / 3, signal);
|
||||
backoff = Math.min(30_000, backoff * 2);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
publishWorkspace(id, { t: 'error', message: error instanceof IntegrityError ? '内容校验失败,已停止接收。' : errorMessage(error) });
|
||||
publishWorkspace(id, { t: 'connection', state: 'offline' });
|
||||
publishWorkspace(id, { t: 'ready' });
|
||||
}
|
||||
} finally {
|
||||
clearInterval(flushTimer);
|
||||
if (active.get(id) === controller) active.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
export function stopChannel(id: string): void {
|
||||
active.get(id)?.abort();
|
||||
active.delete(id);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { supportsApiContract } from '../contract';
|
||||
|
||||
export type ApiError = Error & { status?: number; code?: string };
|
||||
let apiBase = '/api/v1';
|
||||
let contractCheck: Promise<void> | undefined;
|
||||
|
||||
export function setApiBase(value: string): void {
|
||||
if (apiBase === value) return;
|
||||
apiBase = value;
|
||||
contractCheck = undefined;
|
||||
}
|
||||
|
||||
export const fail = (status: number, code: string): ApiError => Object.assign(new Error(code), { status, code });
|
||||
export const apiPath = (path: string): string => `${apiBase.replace(/\/$/, '')}${path}`;
|
||||
|
||||
function ensureApiContract(): Promise<void> {
|
||||
contractCheck ??= fetch(apiPath('/info'), {
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
referrerPolicy: 'no-referrer',
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) throw new Error('api_info_unavailable');
|
||||
const info = await response.json() as { contract_version?: unknown };
|
||||
if (!supportsApiContract(info.contract_version)) throw new Error('api_contract_mismatch');
|
||||
}).catch((error: unknown) => {
|
||||
contractCheck = undefined;
|
||||
throw error;
|
||||
});
|
||||
return contractCheck;
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
try {
|
||||
if (path !== '/info') await ensureApiContract();
|
||||
const response = await fetch(apiPath(path), {
|
||||
...init,
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
referrerPolicy: 'no-referrer',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null) as { error?: unknown } | null;
|
||||
const code = typeof body?.error === 'string' && /^[a-z_]{1,40}$/.test(body.error) ? body.error : 'request_failed';
|
||||
throw fail(response.status, code);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (init.signal?.aborted) throw new DOMException('请求已取消', 'AbortError');
|
||||
if (error instanceof Error && 'status' in error) throw error;
|
||||
if (error instanceof Error && error.message === 'api_contract_mismatch') throw error;
|
||||
throw new Error('网络连接失败');
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJson<T>(path: string, headers: Record<string, string>): Promise<T> {
|
||||
return apiFetch(path, { headers }).then((response) => response.json() as Promise<T>);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export function toCoreLink(link: string): string {
|
||||
const parsed = new URL(link);
|
||||
if (parsed.pathname.startsWith('/s/')) parsed.pathname = parsed.pathname.replace(/^\/s\//, '/c/');
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
export function toShareLink(link: string): string {
|
||||
const parsed = new URL(link);
|
||||
parsed.pathname = parsed.pathname.replace(/^\/c\//, '/s/');
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
export function makeLink(origin: string, id: string, keys: Record<string, string | undefined>): string {
|
||||
const fragment = Object.entries(keys).filter((entry): entry is [string, string] => Boolean(entry[1]))
|
||||
.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
return `${origin.replace(/\/$/, '')}/c/${id}${fragment ? `#${fragment}` : ''}`;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ShareEvent, WorkspaceEvent } from '../types';
|
||||
|
||||
type BridgeEvent = WorkspaceEvent | ShareEvent;
|
||||
type EventSink = (resourceId: string, event: BridgeEvent, blob?: Blob) => void;
|
||||
|
||||
let sink: EventSink = () => {};
|
||||
|
||||
export function setEventSink(next: EventSink): void {
|
||||
sink = next;
|
||||
}
|
||||
|
||||
export function publish(resourceId: string, event: BridgeEvent, blob?: Blob): void {
|
||||
sink(resourceId, event, blob);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ItemEvent } from '@webdrop/core';
|
||||
import type { Device, FileItem, ShareEvent, WorkspaceEvent } from '../types';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import { publish } from './port';
|
||||
export function deviceIdentity(): { name: string; kind: Device['kind'] } {
|
||||
const mobile = /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent);
|
||||
return { name: mobile ? '本机 · 移动设备' : '本机 · 浏览器', kind: mobile ? 'phone' : 'desktop' };
|
||||
}
|
||||
|
||||
export function deviceInfo(id: string, joinedAt: number, label: string | undefined, selfId: string): Device {
|
||||
return { id, name: label ?? '未知设备', kind: 'desktop', online: id === selfId, self: id === selfId, lastSeen: joinedAt };
|
||||
}
|
||||
export function showRejected(resourceId: string, event: Extract<ItemEvent, { type: 'rejected' }>, share = false): void {
|
||||
if (share) publish(resourceId, { t: 'integrity', message: '分享清单校验失败,内容已拒绝显示。' });
|
||||
else if (event.itemId) publish(resourceId, {
|
||||
t: 'item', item: { id: event.itemId, kind: 'text', ts: Date.now(), deviceId: '未知设备', state: { kind: 'tampered' }, text: '' },
|
||||
});
|
||||
else publish(resourceId, { t: 'error', message: '内容校验失败,已拒绝该记录。' });
|
||||
}
|
||||
|
||||
export function makeFileItem(id: string, name: string, mime: string, size: number, ts: number, state: FileItem['state'], remaining: number | null = null): FileItem {
|
||||
return { id, kind: 'file', ts, deviceId: '创建者', state, name, mime, size, remaining, preview: previewKindOf(mime, name) };
|
||||
}
|
||||
export function publishWorkspace(resourceId: string, event: WorkspaceEvent): void {
|
||||
publish(resourceId, event);
|
||||
}
|
||||
|
||||
export function publishShare(resourceId: string, event: ShareEvent): void {
|
||||
publish(resourceId, event);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
const running = new Map<string, Promise<unknown>>();
|
||||
|
||||
export function serial<T>(key: string, action: () => Promise<T>): Promise<T> {
|
||||
const previous = running.get(key) ?? Promise.resolve();
|
||||
const next = previous.catch(() => undefined).then(action);
|
||||
running.set(key, next);
|
||||
void next.finally(() => { if (running.get(key) === next) running.delete(key); }).catch(() => undefined);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ChannelSession } from '@webdrop/core';
|
||||
import { getResource, type SavedResource } from '../database';
|
||||
import { fail } from './http';
|
||||
import { toCoreLink } from './links';
|
||||
export const sessions = new Map<string, ChannelSession>();
|
||||
export const readers = new Map<string, ReturnType<ChannelSession['createItemReader']>>();
|
||||
export const active = new Map<string, AbortController>();
|
||||
export const blobs = new Map<string, Blob>();
|
||||
const opening = new Map<string, Promise<ChannelSession>>();
|
||||
export async function getSession(id: string): Promise<{ session: ChannelSession; resource: SavedResource }> {
|
||||
const resource = await getResource(id);
|
||||
if (!resource) throw fail(404, 'not_found');
|
||||
let session = sessions.get(id);
|
||||
if (!session) {
|
||||
const sourceLink = resource.ownerLink ?? resource.link;
|
||||
let task = opening.get(id);
|
||||
if (!task) {
|
||||
task = ChannelSession.open(toCoreLink(sourceLink), { endpointSecret: resource.endpointSecret });
|
||||
opening.set(id, task);
|
||||
}
|
||||
try { session = await task; } finally { opening.delete(id); }
|
||||
sessions.set(id, session);
|
||||
}
|
||||
return { session, resource };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { dequeueItem, removeDraftFile, saveDraftFile, saveResource, type ManifestEntry } from '../database';
|
||||
import type { FileItem, TextItem } from '../types';
|
||||
import { makeFileItem } from './presentation';
|
||||
import { getSession } from './sessions';
|
||||
import { apiFetch, fail } from './http';
|
||||
|
||||
|
||||
import { flushOutbox, persistPlan } from './transfer';
|
||||
import { publishShare } from './presentation';
|
||||
import { invalidateManifest } from './shareState';
|
||||
import { blobs } from './sessions';
|
||||
|
||||
|
||||
function newTextEntry(text: string): { item: TextItem; entry: Extract<ManifestEntry, { t: 'text' }> } {
|
||||
const ts = Date.now();
|
||||
const localId = `draft-text-${crypto.randomUUID()}`;
|
||||
return {
|
||||
item: { id: localId, kind: 'text', ts, deviceId: '创建者', state: { kind: 'complete' }, text },
|
||||
entry: { t: 'text', localId, text, ts },
|
||||
};
|
||||
}
|
||||
|
||||
export async function addShareText(id: string, text: string): Promise<void> {
|
||||
const { session, resource } = await getSession(id);
|
||||
if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存');
|
||||
await invalidateManifest(session, resource);
|
||||
const { item, entry } = newTextEntry(text);
|
||||
resource.draftItems = [...(resource.draftItems ?? []), item];
|
||||
resource.manifestEntries = [...(resource.manifestEntries ?? []), entry];
|
||||
await saveResource(resource);
|
||||
publishShare(id, { t: 'item', item });
|
||||
}
|
||||
|
||||
export async function addShareFiles(id: string, files: File[]): Promise<void> {
|
||||
const { session, resource } = await getSession(id);
|
||||
if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存');
|
||||
await invalidateManifest(session, resource);
|
||||
const downloadLimit = resource.maxDownloads ?? 0;
|
||||
for (const file of files) {
|
||||
const ts = Date.now();
|
||||
const plan = session.encryptFile(file, { name: file.name, mime: file.type || 'application/octet-stream', size: file.size }, {
|
||||
gated: true, maxDownloads: downloadLimit, ts,
|
||||
});
|
||||
const item = makeFileItem(plan.itemId, file.name, file.type || 'application/octet-stream', file.size, ts, { kind: 'receiving', progress: 0 }, downloadLimit || null);
|
||||
const entry: Extract<ManifestEntry, { t: 'file' }> = { t: 'file', id: plan.itemId, name: file.name, mime: file.type || 'application/octet-stream', size: file.size, ts };
|
||||
blobs.set(`${id}:${item.id}`, file);
|
||||
try { await saveDraftFile(id, item.id, file); }
|
||||
catch { publishShare(id, { t: 'error', message: '本机存储空间不足,刷新后可能无法预览此文件。' }); }
|
||||
resource.draftItems = [...(resource.draftItems ?? []), item];
|
||||
resource.manifestEntries = [...(resource.manifestEntries ?? []), entry];
|
||||
await saveResource(resource);
|
||||
publishShare(id, { t: 'item', item });
|
||||
await persistPlan(resource, plan);
|
||||
const uploaded = await flushOutbox(resource, session);
|
||||
const complete: FileItem = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } };
|
||||
resource.draftItems = resource.draftItems.map((draft) => draft.id === item.id ? complete : draft);
|
||||
await saveResource(resource);
|
||||
publishShare(id, { t: 'item', item: complete });
|
||||
if (!uploaded) {
|
||||
publishShare(id, { t: 'error', message: '上传暂停了。重新打开草稿并连接网络后,可以继续发送已加密帧。' });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function editShareText(id: string, itemId: string, text: string): Promise<void> {
|
||||
const { session, resource } = await getSession(id);
|
||||
if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存');
|
||||
const item = resource.draftItems?.find((entry): entry is TextItem => entry.id === itemId && entry.kind === 'text');
|
||||
if (!item) throw fail(404, 'not_found');
|
||||
await invalidateManifest(session, resource);
|
||||
const ts = Date.now();
|
||||
const updated: TextItem = { ...item, text, ts };
|
||||
resource.draftItems = (resource.draftItems ?? []).map((entry) => entry.id === itemId ? updated : entry);
|
||||
resource.manifestEntries = (resource.manifestEntries ?? []).map((entry) =>
|
||||
entry.t === 'text' && entry.localId === itemId ? { ...entry, text, ts } : entry);
|
||||
await saveResource(resource);
|
||||
publishShare(id, { t: 'item', item: updated });
|
||||
}
|
||||
|
||||
export async function removeShareItem(id: string, itemId: string): Promise<void> {
|
||||
const { session, resource } = await getSession(id);
|
||||
if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存');
|
||||
await invalidateManifest(session, resource);
|
||||
const item = resource.draftItems?.find((entry) => entry.id === itemId);
|
||||
if (!item) throw fail(404, 'not_found');
|
||||
if (item.kind === 'file') {
|
||||
try { await apiFetch(`/shares/${id}/items/${itemId}`, { method: 'DELETE', headers: session.authHeaders('owner') }); }
|
||||
catch (error) { if ((error as { status?: number }).status !== 404) throw error; }
|
||||
await dequeueItem(id, itemId);
|
||||
await removeDraftFile(id, itemId);
|
||||
blobs.delete(`${id}:${itemId}`);
|
||||
}
|
||||
resource.draftItems = (resource.draftItems ?? []).filter((entry) => entry.id !== itemId);
|
||||
resource.manifestEntries = (resource.manifestEntries ?? []).filter((entry) => entry.t === 'file' ? entry.id !== itemId : entry.localId !== itemId);
|
||||
await saveResource(resource);
|
||||
publishShare(id, { t: 'removed', id: itemId });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { IntegrityError, type ChannelSession } from '@webdrop/core';
|
||||
import type { ManifestEntry } from '../database';
|
||||
import type { ShareWire } from './wire';
|
||||
import { apiFetch } from './http';
|
||||
|
||||
export type ManifestFile = { t: 'file'; id: string; name?: string; mime?: string; size: number; ts: number };
|
||||
export type ManifestText = { t: 'text'; name?: string; text: string; ts: number };
|
||||
export type ProtocolManifestEntry = ManifestFile | ManifestText;
|
||||
export interface ShareManifest { v: 1; items: ProtocolManifestEntry[] }
|
||||
|
||||
export function encodeManifest(entries: ManifestEntry[]): Uint8Array {
|
||||
const items: ProtocolManifestEntry[] = entries.map((entry) => entry.t === 'file'
|
||||
? { t: 'file', id: entry.id, name: entry.name, mime: entry.mime, size: entry.size, ts: entry.ts }
|
||||
: { t: 'text', ...(entry.name === undefined ? {} : { name: entry.name }), text: entry.text, ts: entry.ts });
|
||||
return new TextEncoder().encode(JSON.stringify({ v: 1, items }));
|
||||
}
|
||||
|
||||
function safeTime(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
export async function fetchManifest(
|
||||
session: ChannelSession,
|
||||
shareId: string,
|
||||
info: ShareWire,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ShareManifest> {
|
||||
if (!info.manifest_item_id) throw new IntegrityError('bad_meta');
|
||||
const response = await apiFetch(`/shares/${shareId}/manifest`, { headers: session.authHeaders('read'), signal });
|
||||
const events = await session.createItemReader().pushFrame(new Uint8Array(await response.arrayBuffer()));
|
||||
const rejection = events.find((event) => event.type === 'rejected');
|
||||
if (rejection?.type === 'rejected') throw new IntegrityError(rejection.reason);
|
||||
const found = events.find((event) => event.type === 'item');
|
||||
if (!found || found.type !== 'item' || found.item.kind !== 'manifest' || !found.item.complete
|
||||
|| found.item.itemId !== info.manifest_item_id) throw new IntegrityError('bad_meta');
|
||||
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(found.item.content)); }
|
||||
catch { throw new IntegrityError('bad_meta'); }
|
||||
if (typeof value !== 'object' || value === null || !('v' in value) || value.v !== 1 || !('items' in value) || !Array.isArray(value.items))
|
||||
throw new IntegrityError('bad_meta');
|
||||
|
||||
const listed = new Set<string>();
|
||||
const validated: ProtocolManifestEntry[] = [];
|
||||
for (const raw of value.items) {
|
||||
if (typeof raw !== 'object' || raw === null || !('t' in raw) || !safeTime('ts' in raw ? raw.ts : undefined))
|
||||
throw new IntegrityError('bad_meta');
|
||||
if (raw.t === 'text' && 'text' in raw && typeof raw.text === 'string') {
|
||||
if ('name' in raw && raw.name !== undefined && typeof raw.name !== 'string') throw new IntegrityError('bad_meta');
|
||||
validated.push({ t: 'text', ...(typeof raw.name === 'string' ? { name: raw.name } : {}), text: raw.text, ts: raw.ts });
|
||||
continue;
|
||||
}
|
||||
if (raw.t === 'file' && 'id' in raw && typeof raw.id === 'string' && /^[A-Za-z0-9_-]{22}$/.test(raw.id)
|
||||
&& 'size' in raw && typeof raw.size === 'number' && Number.isSafeInteger(raw.size) && raw.size >= 0) {
|
||||
if (listed.has(raw.id)) throw new IntegrityError('bad_meta');
|
||||
if ('name' in raw && raw.name !== undefined && typeof raw.name !== 'string') throw new IntegrityError('bad_meta');
|
||||
if ('mime' in raw && raw.mime !== undefined && typeof raw.mime !== 'string') throw new IntegrityError('bad_meta');
|
||||
listed.add(raw.id);
|
||||
validated.push({
|
||||
t: 'file', id: raw.id,
|
||||
...(typeof raw.name === 'string' ? { name: raw.name } : {}),
|
||||
...(typeof raw.mime === 'string' ? { mime: raw.mime } : {}),
|
||||
size: raw.size, ts: raw.ts,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
throw new IntegrityError('bad_meta');
|
||||
}
|
||||
return { v: 1, items: validated };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { ChannelSession, IntegrityError } from '@webdrop/core';
|
||||
import { getDraftFile, saveResource, type SavedResource } from '../database';
|
||||
import type { FileItem, TextItem } from '../types';
|
||||
import { makeFileItem } from './presentation';
|
||||
import { active, blobs, getSession } from './sessions';
|
||||
import { fail, readJson } from './http';
|
||||
import { toShareInfo, type ShareWire } from './wire';
|
||||
|
||||
import { flushOutbox, downloadDecrypted } from './transfer';
|
||||
import { publishShare } from './presentation';
|
||||
import { fetchManifest, type ManifestFile } from './shareManifest';
|
||||
import { readShareInfo } from './shareState';
|
||||
import { errorMessage } from '../errors';
|
||||
|
||||
export async function connectShare(id: string): Promise<void> {
|
||||
active.get(id)?.abort();
|
||||
const controller = new AbortController();
|
||||
active.set(id, controller);
|
||||
try {
|
||||
const opened = await getSession(id);
|
||||
let { session } = opened;
|
||||
const { resource } = opened;
|
||||
if (controller.signal.aborted) return;
|
||||
if (resource.kind !== 'share') throw new Error('资源类型不匹配');
|
||||
const raw = await readShareInfo(session, resource);
|
||||
session = (await getSession(id)).session;
|
||||
if (controller.signal.aborted) return;
|
||||
const share = toShareInfo(raw, resource);
|
||||
resource.state = raw.state;
|
||||
resource.expiresAt = raw.expires_at;
|
||||
await saveResource(resource);
|
||||
publishShare(id, { t: 'share', share });
|
||||
|
||||
if (raw.state === 'draft' && resource.mine) {
|
||||
const resumed = await flushOutbox(resource, session);
|
||||
if (resumed) resource.draftItems = resource.draftItems?.map((item) => item.state.kind === 'incomplete' ? { ...item, state: { kind: 'complete' } } : item);
|
||||
await saveResource(resource);
|
||||
if (controller.signal.aborted) return;
|
||||
for (const item of resource.draftItems ?? []) publishShare(id, { t: 'item', item });
|
||||
publishShare(id, { t: 'ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
await loadSealedManifest(resource, session, raw, controller.signal);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
publishShare(id, error instanceof IntegrityError
|
||||
? { t: 'integrity', message: '分享清单校验失败,内容已拒绝显示。' }
|
||||
: { t: 'error', message: errorMessage(error) });
|
||||
publishShare(id, { t: 'ready' });
|
||||
} finally {
|
||||
if (active.get(id) === controller) active.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
export function stopShare(id: string): void {
|
||||
active.get(id)?.abort();
|
||||
active.delete(id);
|
||||
}
|
||||
|
||||
async function loadSealedManifest(resource: SavedResource, session: ChannelSession, raw: ShareWire, signal: AbortSignal): Promise<void> {
|
||||
try {
|
||||
const manifest = await fetchManifest(session, resource.id, raw, signal);
|
||||
const byId = new Map(raw.items.map((item) => [item.item_id, item]));
|
||||
for (let index = 0; index < manifest.items.length; index++) {
|
||||
const entry = manifest.items[index];
|
||||
if (!entry) throw new Error('invalid_manifest_item');
|
||||
if (entry.t === 'text' && typeof entry.text === 'string' && Number.isSafeInteger(entry.ts)) {
|
||||
const item: TextItem = { id: `manifest-text-${index}`, kind: 'text', ts: entry.ts, deviceId: '创建者', state: { kind: 'complete' }, text: entry.text };
|
||||
publishShare(resource.id, { t: 'item', item });
|
||||
} else if (entry.t === 'file' && typeof entry.id === 'string' && typeof entry.size === 'number' && Number.isSafeInteger(entry.size)) {
|
||||
const detail = byId.get(entry.id);
|
||||
const item = makeFileItem(entry.id, entry.name ?? '加密文件', entry.mime ?? 'application/octet-stream', entry.size,
|
||||
entry.ts, { kind: 'complete' }, detail?.downloads_left ?? (detail ? null : 0));
|
||||
item.gated = true;
|
||||
publishShare(resource.id, { t: 'item', item });
|
||||
} else throw new Error('invalid_manifest_item');
|
||||
}
|
||||
} catch {
|
||||
publishShare(resource.id, { t: 'integrity', message: '分享清单格式不正确,内容已拒绝显示。' });
|
||||
}
|
||||
publishShare(resource.id, { t: 'ready' });
|
||||
}
|
||||
|
||||
export async function getShareBlob(id: string, itemId: string): Promise<Blob> {
|
||||
const cached = blobs.get(`${id}:${itemId}`);
|
||||
if (cached) return cached;
|
||||
const { session, resource } = await getSession(id);
|
||||
if (resource.kind !== 'share') throw new Error('资源类型不匹配');
|
||||
if (resource.state === 'draft' && resource.mine) {
|
||||
const draftFile = await getDraftFile(id, itemId);
|
||||
if (draftFile) {
|
||||
blobs.set(`${id}:${itemId}`, draftFile);
|
||||
return draftFile;
|
||||
}
|
||||
}
|
||||
const raw = await readJson<ShareWire>(`/shares/${id}`, session.authHeaders('read'));
|
||||
const manifest = await fetchManifest(session, id, raw);
|
||||
if (itemId.startsWith('manifest-text-')) {
|
||||
const entry = manifest.items[Number(itemId.slice(14))];
|
||||
if (entry?.t !== 'text') throw fail(404, 'not_found');
|
||||
return new Blob([entry.text], { type: 'text/plain' });
|
||||
}
|
||||
const entry = manifest.items.find((candidate): candidate is ManifestFile => candidate.t === 'file' && candidate.id === itemId);
|
||||
if (!entry) throw fail(404, 'not_found');
|
||||
let result: Awaited<ReturnType<typeof downloadDecrypted>>;
|
||||
try { result = await downloadDecrypted(session, resource, itemId, entry.size); }
|
||||
catch (error) {
|
||||
if (error instanceof IntegrityError) publishShare(id, { t: 'integrity', message: '文件校验失败,内容已丢弃。' });
|
||||
throw error;
|
||||
}
|
||||
blobs.set(`${id}:${itemId}`, result.blob);
|
||||
const fileInfo = raw.items.find((value) => value.item_id === itemId);
|
||||
const remaining = fileInfo?.downloads_left === null || fileInfo?.downloads_left === undefined
|
||||
? null
|
||||
: Math.max(0, fileInfo.downloads_left - 1);
|
||||
const item: FileItem = makeFileItem(itemId, entry.name ?? '加密文件', entry.mime ?? 'application/octet-stream', entry.size, entry.ts,
|
||||
{ kind: 'complete' }, remaining);
|
||||
publishShare(id, { t: 'item', item });
|
||||
return result.blob;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { ChannelSession } from '@webdrop/core';
|
||||
import { getResource, listResources, removeResource, saveResource, type SavedResource } from '../database';
|
||||
import type { Share, ShareSettings } from '../types';
|
||||
|
||||
import { active, blobs, getSession, sessions } from './sessions';
|
||||
import { apiFetch, fail, readJson } from './http';
|
||||
import { apiShareSettings, toShareInfo, type ShareWire } from './wire';
|
||||
import { makeLink, toShareLink } from './links';
|
||||
|
||||
|
||||
|
||||
|
||||
export async function createShare(settings: ShareSettings, captchaToken?: string): Promise<Share> {
|
||||
const endpointSecret = crypto.getRandomValues(new Uint8Array(32));
|
||||
const { session, request } = await ChannelSession.create({ mode: 'enc', endpointSecret });
|
||||
try {
|
||||
const body = {
|
||||
id: request.body.id,
|
||||
read_hash: request.body.read_hash,
|
||||
owner_hash: request.body.owner_hash,
|
||||
settings: apiShareSettings(settings),
|
||||
};
|
||||
const response = await apiFetch('/shares', {
|
||||
method: 'POST', cache: 'no-store',
|
||||
headers: { 'Content-Type': 'application/json', 'X-WD-Endpoint': request.headers['X-WD-Endpoint']!, ...(captchaToken ? { 'X-WD-Cap': captchaToken } : {}) },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const raw = await response.json() as ShareWire;
|
||||
const resource: SavedResource = {
|
||||
id: raw.id, kind: 'share', mode: 'enc', role: 'owner', mine: true,
|
||||
link: toShareLink(session.link(self.location.origin)),
|
||||
ownerLink: toShareLink(session.link(self.location.origin, 'owner')),
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret),
|
||||
createdAt: raw.created_at, expiresAt: raw.expires_at, seats: 0, membersCanUpload: false,
|
||||
state: 'draft', maxVisitors: raw.settings.max_visitors,
|
||||
ttlSeconds: raw.settings.ttl_seconds,
|
||||
maxDownloads: raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads,
|
||||
draftItems: [], manifestEntries: [],
|
||||
};
|
||||
await saveResource(resource);
|
||||
sessions.set(raw.id, session);
|
||||
return toShareInfo(raw, resource);
|
||||
} catch (error) {
|
||||
session.dispose();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function joinShare(input: { link: { id: string; key: string } }): Promise<Share> {
|
||||
const existing = await getResource(input.link.id);
|
||||
const endpointSecret = existing?.endpointSecret ?? crypto.getRandomValues(new Uint8Array(32));
|
||||
const session = await ChannelSession.open(makeLink(self.location.origin, input.link.id, { k: input.link.key }), { endpointSecret });
|
||||
try {
|
||||
const raw = await readJson<ShareWire>(`/shares/${input.link.id}`, session.authHeaders('read'));
|
||||
if (raw.state !== 'sealed') throw fail(404, 'not_found');
|
||||
const resource: SavedResource = {
|
||||
id: input.link.id, kind: 'share', mode: 'enc', role: 'member', mine: false,
|
||||
link: toShareLink(makeLink(self.location.origin, input.link.id, { k: input.link.key })),
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: raw.created_at, expiresAt: raw.expires_at,
|
||||
seats: 0, membersCanUpload: false, state: raw.state,
|
||||
ttlSeconds: raw.settings.ttl_seconds,
|
||||
maxVisitors: raw.settings.max_visitors, maxDownloads: raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads,
|
||||
};
|
||||
await saveResource(resource);
|
||||
sessions.set(resource.id, session);
|
||||
return toShareInfo(raw, resource);
|
||||
} catch (error) {
|
||||
session.dispose();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateShareSettings(id: string, patch: Partial<ShareSettings>): Promise<Share> {
|
||||
const { session, resource } = await getSession(id);
|
||||
if (resource.kind !== 'share' || !session.can('owner')) throw new Error('此分享不再可修改');
|
||||
const body: Record<string, number> = {};
|
||||
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
|
||||
if (patch.maxVisitors !== undefined) body.max_visitors = patch.maxVisitors;
|
||||
if (patch.maxDownloads !== undefined) body.file_max_downloads = patch.maxDownloads ?? 0;
|
||||
const raw = await apiFetch(`/shares/${id}/settings`, {
|
||||
method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
}).then((response) => response.json() as Promise<ShareWire>);
|
||||
resource.expiresAt = raw.expires_at;
|
||||
resource.ttlSeconds = raw.settings.ttl_seconds;
|
||||
resource.maxVisitors = raw.settings.max_visitors;
|
||||
resource.maxDownloads = raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads;
|
||||
await saveResource(resource);
|
||||
return toShareInfo(raw, resource);
|
||||
}
|
||||
|
||||
export async function removeShare(id: string): Promise<void> {
|
||||
active.get(id)?.abort();
|
||||
active.delete(id);
|
||||
sessions.get(id)?.dispose();
|
||||
sessions.delete(id);
|
||||
for (const key of blobs.keys()) if (key.startsWith(`${id}:`)) blobs.delete(key);
|
||||
await removeResource(id);
|
||||
}
|
||||
|
||||
export async function listSavedShares(): Promise<Share[]> {
|
||||
const saved = await listResources('share');
|
||||
return saved.map((resource) => ({
|
||||
id: resource.id, state: resource.state ?? 'draft', mine: resource.mine ?? false,
|
||||
link: resource.link, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
|
||||
ttlSeconds: resource.ttlSeconds ?? 86400, maxVisitors: resource.maxVisitors ?? 0,
|
||||
maxDownloads: resource.maxDownloads ?? null,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { saveResource } from '../database';
|
||||
import type { Item, Share } from '../types';
|
||||
import { getSession, sessions } from './sessions';
|
||||
import { apiFetch } from './http';
|
||||
import { toShareInfo, type ShareWire } from './wire';
|
||||
import { flushOutbox, persistPlan } from './transfer';
|
||||
import { publishShare } from './presentation';
|
||||
import { encodeManifest } from './shareManifest';
|
||||
import { readShareInfo, releaseOwner } from './shareState';
|
||||
|
||||
export async function sealShare(id: string): Promise<Share> {
|
||||
const { session, resource } = await getSession(id);
|
||||
if (resource.kind !== 'share' || resource.state !== 'draft' || !session.can('owner')) throw new Error('此分享已封存');
|
||||
if (!(await flushOutbox(resource, session))) throw new Error('网络连接失败,请稍后重试');
|
||||
const before = await readShareInfo(session, resource);
|
||||
if (before.state === 'sealed') return toShareInfo(before, resource);
|
||||
if ((resource.draftItems ?? []).some((item) => item.state.kind !== 'complete')) throw new Error('还有内容没有上传完成');
|
||||
if (!resource.pendingManifestId) {
|
||||
const bytes = encodeManifest(resource.manifestEntries ?? []);
|
||||
const plan = session.encryptBytes(bytes, { kind: 'manifest' }, { gated: false });
|
||||
await persistPlan(resource, plan);
|
||||
resource.pendingManifestId = plan.itemId;
|
||||
await saveResource(resource);
|
||||
}
|
||||
if (!(await flushOutbox(resource, session))) throw new Error('清单上传暂停,请重试');
|
||||
let raw: ShareWire;
|
||||
try {
|
||||
const response = await apiFetch(`/shares/${id}/finish`, {
|
||||
method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ manifest_item_id: resource.pendingManifestId }),
|
||||
});
|
||||
raw = await response.json() as ShareWire;
|
||||
} catch (error) {
|
||||
raw = await readShareInfo(session, resource).catch(() => { throw error; });
|
||||
if (raw.state !== 'sealed') throw error;
|
||||
}
|
||||
const items = resource.draftItems ?? [];
|
||||
if (sessions.get(id)?.can('owner')) await releaseOwner(session, resource, raw);
|
||||
const sealed = toShareInfo(raw, resource);
|
||||
publishShare(id, { t: 'share', share: sealed });
|
||||
const remaining = new Map(raw.items.map((item) => [item.item_id, item.downloads_left]));
|
||||
for (const draft of items) {
|
||||
const item: Item = draft.kind === 'file' ? { ...draft, remaining: remaining.get(draft.id) ?? null } : draft;
|
||||
publishShare(id, { t: 'item', item });
|
||||
}
|
||||
publishShare(id, { t: 'ready' });
|
||||
return sealed;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ChannelSession } from '@webdrop/core';
|
||||
import { clearDraftFiles, dequeueItem, saveResource, type SavedResource } from '../database';
|
||||
import { blobs, sessions } from './sessions';
|
||||
import { apiFetch, readJson } from './http';
|
||||
import { toCoreLink } from './links';
|
||||
import type { ShareWire } from './wire';
|
||||
|
||||
export async function readShareInfo(session: ChannelSession, resource: SavedResource): Promise<ShareWire> {
|
||||
const role = resource.state === 'draft' && session.can('owner') ? 'owner' : 'read';
|
||||
let raw: ShareWire;
|
||||
try { raw = await readJson<ShareWire>(`/shares/${resource.id}`, session.authHeaders(role)); }
|
||||
catch (error) {
|
||||
if (role !== 'owner' || (error as { status?: number }).status !== 401) throw error;
|
||||
// finish 响应丢失时,读凭据仍可确认服务端已封存。
|
||||
raw = await readJson<ShareWire>(`/shares/${resource.id}`, session.authHeaders('read'));
|
||||
}
|
||||
if (raw.id !== resource.id) throw new Error('分享信息与链接不符');
|
||||
if (raw.state === 'sealed' && session.can('owner')) await releaseOwner(session, resource, raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function releaseOwner(session: ChannelSession, resource: SavedResource, raw: ShareWire): Promise<void> {
|
||||
const reader = await ChannelSession.open(toCoreLink(resource.link), { endpointSecret: resource.endpointSecret });
|
||||
for (const key of blobs.keys()) if (key.startsWith(`${resource.id}:`)) blobs.delete(key);
|
||||
try { await clearDraftFiles(resource.id); }
|
||||
catch { /* 清理失败不能让已封存资源继续保留 owner 会话 */ }
|
||||
session.dispose();
|
||||
sessions.set(resource.id, reader);
|
||||
resource.ownerLink = undefined;
|
||||
resource.role = 'member';
|
||||
resource.state = 'sealed';
|
||||
resource.expiresAt = raw.expires_at;
|
||||
resource.createdAt = raw.sealed_at ?? raw.created_at;
|
||||
resource.ttlSeconds = raw.settings.ttl_seconds;
|
||||
resource.pendingManifestId = undefined;
|
||||
resource.draftItems = [];
|
||||
resource.manifestEntries = [];
|
||||
await saveResource(resource);
|
||||
}
|
||||
|
||||
export async function invalidateManifest(session: ChannelSession, resource: SavedResource): Promise<void> {
|
||||
const id = resource.pendingManifestId;
|
||||
if (!id) return;
|
||||
try { await apiFetch(`/shares/${resource.id}/items/${id}`, { method: 'DELETE', headers: session.authHeaders('owner') }); }
|
||||
catch (error) { if ((error as { status?: number }).status !== 404) throw error; }
|
||||
await dequeueItem(resource.id, id);
|
||||
resource.pendingManifestId = undefined;
|
||||
await saveResource(resource);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { fromBase64Url, IntegrityError, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core';
|
||||
import { dequeueFrame, queueFrame, nextQueuedFrame, type SavedResource } from '../database';
|
||||
import { apiFetch, type ApiError } from './http';
|
||||
import { serial } from './serial';
|
||||
|
||||
export async function persistPlan(resource: SavedResource, plan: ItemPlan): Promise<void> {
|
||||
const createdAt = Date.now();
|
||||
for await (const frame of plan.frames) {
|
||||
await queueFrame({
|
||||
key: `${resource.id}:${plan.itemId}:${String(frame.index).padStart(10, '0')}`,
|
||||
resourceId: resource.id,
|
||||
itemId: plan.itemId,
|
||||
index: frame.index,
|
||||
bytes: frame.bytes,
|
||||
extraHeaders: frame.index === 0 ? { ...plan.frame0Headers } : {},
|
||||
createdAt: createdAt + frame.index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function writeRole(session: ChannelSession): 'owner' | 'write' {
|
||||
if (session.can('owner')) return 'owner';
|
||||
if (session.can('write')) return 'write';
|
||||
throw new Error('此链接没有上传权限');
|
||||
}
|
||||
|
||||
/** The outbox contains ciphertext only. Every retry sends these exact bytes. */
|
||||
export async function flushOutbox(resource: SavedResource, session: ChannelSession): Promise<boolean> {
|
||||
return serial(`outbox:${resource.id}`, () => flush(resource, session));
|
||||
}
|
||||
|
||||
async function flush(resource: SavedResource, session: ChannelSession): Promise<boolean> {
|
||||
for (;;) {
|
||||
const frame = await nextQueuedFrame(resource.id);
|
||||
if (!frame) return true;
|
||||
const role = resource.kind === 'share' ? 'owner' : writeRole(session);
|
||||
const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : `/channels/${resource.id}/frames`;
|
||||
try {
|
||||
await apiFetch(path, {
|
||||
method: 'POST',
|
||||
headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...frame.extraHeaders },
|
||||
body: frame.bytes.slice().buffer as ArrayBuffer,
|
||||
});
|
||||
await dequeueFrame(frame.key);
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError;
|
||||
if (apiError.status === undefined || apiError.status === 429 || apiError.status >= 500) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadDecrypted(
|
||||
session: ChannelSession,
|
||||
resource: SavedResource,
|
||||
itemId: string,
|
||||
expectedSize?: number,
|
||||
): Promise<{ blob: Blob; meta: ItemDecodeResult['meta'] }> {
|
||||
const role = session.can('read') ? 'read' : 'owner';
|
||||
const path = resource.kind === 'share'
|
||||
? `/shares/${resource.id}/items/${itemId}/frames?from=0`
|
||||
: `/channels/${resource.id}/items/${itemId}/frames?from=0`;
|
||||
const response = await apiFetch(path, { headers: session.authHeaders(role) });
|
||||
if (!response.body) throw new Error('服务端没有返回文件内容');
|
||||
const expectedId = fromBase64Url(itemId, 16);
|
||||
if (!expectedId) throw new IntegrityError('bad_meta');
|
||||
const decoded = session.decryptItemStream(response.body, { itemId: expectedId });
|
||||
const blobPromise = new Response(decoded.content).blob();
|
||||
const [result, blob] = await Promise.all([decoded.result, blobPromise]);
|
||||
if (!result.complete) throw new Error('文件传输不完整');
|
||||
if (result.meta?.size !== undefined && result.meta.size !== blob.size) throw new IntegrityError('bad_meta');
|
||||
if (expectedSize !== undefined && expectedSize !== blob.size) throw new IntegrityError('bad_meta');
|
||||
return { blob, meta: result.meta };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { fromBase64Url, toBase64Url, type ChannelSession } from '@webdrop/core';
|
||||
import { apiFetch, apiPath } from './http';
|
||||
|
||||
export interface StreamConsumer {
|
||||
cursor: number;
|
||||
record(bytes: Uint8Array): Promise<void>;
|
||||
control(value: { t?: string; seq?: number }): Promise<void>;
|
||||
online(): void;
|
||||
}
|
||||
|
||||
export function socketStream(id: string, session: ChannelSession, signal: AbortSignal, consumer: StreamConsumer): Promise<void> {
|
||||
if (signal.aborted) return Promise.resolve();
|
||||
const address = new URL(apiPath(`/channels/${id}/ws?from=${consumer.cursor}`), self.location.origin);
|
||||
address.protocol = address.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = session.authHeaders('read').Authorization?.slice(7);
|
||||
const socket = new WebSocket(address, ['wd.v1', 'wd.role.read', ...(token ? [`wd.auth.${token}`] : []), `wd.ep.${toBase64Url(session.endpointSecret)}`]);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
return new Promise((resolve, reject) => {
|
||||
let chain = Promise.resolve();
|
||||
let failure: unknown;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const heartbeat = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => { failure = new Error('连接超时'); socket.close(); }, 45_000);
|
||||
};
|
||||
const abort = () => socket.close();
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
heartbeat();
|
||||
socket.onopen = () => consumer.online();
|
||||
socket.onmessage = (message) => {
|
||||
heartbeat();
|
||||
// 建立连接起就排队,异步解密期间不会错过下一帧。
|
||||
chain = chain.then(async () => {
|
||||
if (signal.aborted) return;
|
||||
if (typeof message.data === 'string') await consumer.control(JSON.parse(message.data));
|
||||
else await consumer.record(new Uint8Array(message.data as ArrayBuffer));
|
||||
}).catch((error: unknown) => { failure = error; socket.close(); });
|
||||
};
|
||||
socket.onerror = () => { failure = new Error('连接失败'); socket.close(); };
|
||||
socket.onclose = () => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', abort);
|
||||
void chain.then(() => { if (failure && !signal.aborted) reject(failure); else resolve(); });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function sseStream(id: string, session: ChannelSession, signal: AbortSignal, consumer: StreamConsumer): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
let timer = setTimeout(abort, 45_000);
|
||||
try {
|
||||
if (signal.aborted) return;
|
||||
const response = await apiFetch(`/channels/${id}/events?from=${consumer.cursor}`, { headers: session.authHeaders('read'), signal: controller.signal });
|
||||
if (!response.body) throw new Error('连接失败');
|
||||
consumer.online();
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(abort, 45_000);
|
||||
buffer += decoder.decode(chunk.value, { stream: true }).replace(/\r/g, '');
|
||||
let boundary: number;
|
||||
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
|
||||
const block = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
const lines = block.split('\n');
|
||||
const event = lines.find((line) => line.startsWith('event:'))?.slice(6).trim();
|
||||
const data = lines.filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');
|
||||
if (event === 'log') {
|
||||
const bytes = fromBase64Url(data);
|
||||
if (!bytes) throw new Error('内容校验失败');
|
||||
await consumer.record(bytes);
|
||||
} else if (event === 'control') await consumer.control(JSON.parse(data));
|
||||
else if (event === 'error') throw new Error('连接权限已失效');
|
||||
}
|
||||
}
|
||||
} finally { await reader.cancel().catch(() => undefined); reader.releaseLock(); }
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', abort);
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
export function delay(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal.aborted) return resolve();
|
||||
const finish = () => { clearTimeout(timer); signal.removeEventListener('abort', finish); resolve(); };
|
||||
const timer = setTimeout(finish, ms);
|
||||
signal.addEventListener('abort', finish, { once: true });
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ShareEvent, WorkspaceEvent } from './types';
|
||||
|
||||
type BridgeEvent = WorkspaceEvent | ShareEvent;
|
||||
type Pending = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
|
||||
let worker: Worker | undefined;
|
||||
let nextId = 1;
|
||||
const pending = new Map<number, Pending>();
|
||||
const listeners = new Map<string, Set<(event: BridgeEvent, blob?: Blob) => void>>();
|
||||
|
||||
function getWorker(): Worker {
|
||||
worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module', name: 'webdrop-crypto' });
|
||||
worker.onmessage = (message: MessageEvent<WorkerReply>) => {
|
||||
const reply = message.data;
|
||||
if ('requestId' in reply) {
|
||||
const task = pending.get(reply.requestId);
|
||||
if (!task) return;
|
||||
pending.delete(reply.requestId);
|
||||
if ('error' in reply) task.reject(new Error(reply.error));
|
||||
else task.resolve(reply.result);
|
||||
return;
|
||||
}
|
||||
const blob = reply.blob ? new Blob([reply.blob], { type: reply.mime ?? 'application/octet-stream' }) : undefined;
|
||||
for (const listener of listeners.get(reply.resourceId) ?? []) listener(reply.event, blob);
|
||||
};
|
||||
worker.onerror = () => {
|
||||
for (const task of pending.values()) task.reject(new Error('加密工作线程不可用'));
|
||||
pending.clear();
|
||||
};
|
||||
return worker;
|
||||
}
|
||||
|
||||
interface WorkerResponse {
|
||||
requestId: number;
|
||||
result: unknown;
|
||||
error?: never;
|
||||
}
|
||||
|
||||
interface WorkerErrorResponse {
|
||||
requestId: number;
|
||||
error: string;
|
||||
result?: never;
|
||||
}
|
||||
|
||||
interface WorkerEventReply {
|
||||
resourceId: string;
|
||||
event: BridgeEvent;
|
||||
blob?: ArrayBuffer;
|
||||
mime?: string;
|
||||
}
|
||||
|
||||
type WorkerReply = WorkerResponse | WorkerErrorResponse | WorkerEventReply;
|
||||
|
||||
export function callWorker<T>(operation: string, payload: unknown = {}): Promise<T> {
|
||||
const requestId = nextId++;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
pending.set(requestId, { resolve: (value) => resolve(value as T), reject });
|
||||
getWorker().postMessage({ requestId, operation, payload });
|
||||
});
|
||||
}
|
||||
|
||||
export function subscribeWorker<T extends BridgeEvent>(resourceId: string, listener: (event: T, blob?: Blob) => void): () => void {
|
||||
const bucket = listeners.get(resourceId) ?? new Set();
|
||||
bucket.add(listener as (event: BridgeEvent, blob?: Blob) => void);
|
||||
listeners.set(resourceId, bucket);
|
||||
return () => {
|
||||
bucket.delete(listener as (event: BridgeEvent, blob?: Blob) => void);
|
||||
if (bucket.size === 0) listeners.delete(resourceId);
|
||||
};
|
||||
}
|
||||
|
||||
export type { BridgeEvent };
|
||||
@@ -68,3 +68,15 @@
|
||||
font-size: 18px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
position: fixed;
|
||||
top: var(--bar-h);
|
||||
left: var(--space-4);
|
||||
right: var(--space-4);
|
||||
z-index: 20;
|
||||
padding: var(--space-3);
|
||||
background: var(--surface);
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--danger);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export function Workspace() {
|
||||
const sendFiles = useWorkspace((s) => s.sendFiles);
|
||||
const sendText = useWorkspace((s) => s.sendText);
|
||||
const remove = useWorkspace((s) => s.remove);
|
||||
const error = useWorkspace((s) => s.error);
|
||||
const download = useDownload();
|
||||
|
||||
// 刚创建的频道:直接展示分享面板和二维码,让对方马上能扫
|
||||
@@ -61,10 +62,7 @@ export function Workspace() {
|
||||
|
||||
// 从首页「快速分享」带过来的内容,进入频道后发送
|
||||
useEffect(() => {
|
||||
const { consumePayload, clearJustCreated } = useChannels.getState();
|
||||
const p = consumePayload();
|
||||
if (p) sendPayload(p);
|
||||
clearJustCreated();
|
||||
useChannels.getState().clearJustCreated();
|
||||
}, [sendPayload]);
|
||||
|
||||
const ids = useWorkspace(
|
||||
@@ -192,6 +190,7 @@ export function Workspace() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? <div className={styles.error} role="alert">{error}</div> : null}
|
||||
{!desktop && panelOpen ? (
|
||||
<Sheet title="设备与分享" onClose={closePanel}>
|
||||
<DevicePanel qrOpen={justCreated} />
|
||||
|
||||
@@ -36,7 +36,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
|
||||
<span className={styles.enc}><Icon name="lock" size={12} /> 加密</span>
|
||||
<span className={c.state === 'sealed' ? styles.enc : styles.raw}>{c.state === 'sealed' ? '已封存' : '草稿'}</span>
|
||||
<span>{c.mine ? '我创建的' : '收到的'}</span>
|
||||
<span>{formatRemaining(c.createdAt + c.ttlSeconds * 1000)}</span>
|
||||
<span>{formatRemaining(c.expiresAt)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ChannelInfo, CreateOptions } from '@/core-bridge/types';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import { solveCreationCaptcha } from '@/core-bridge/captcha';
|
||||
import styles from './CreateSheet.module.css';
|
||||
|
||||
const SEATS = [2, 3, 5, 10];
|
||||
@@ -20,7 +21,7 @@ interface Props {
|
||||
/** 新建频道:协议 §12 里与创建相关的设置子集 */
|
||||
export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
const create = useChannels((s) => s.create);
|
||||
const [opts, setOpts] = useState<CreateOptions>({ kind: 'channel', mode: 'enc', seats: 3, ttlSeconds: 86400, membersCanUpload: true, maxDownloads: null });
|
||||
const [opts, setOpts] = useState<CreateOptions>({ mode: 'enc', seats: 3, ttlSeconds: 86400, membersCanUpload: true });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const set = <K extends keyof CreateOptions>(k: K, v: CreateOptions[K]) => setOpts((o) => ({ ...o, [k]: v }));
|
||||
@@ -29,7 +30,8 @@ export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
setBusy(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
onCreated(await create(opts));
|
||||
const captchaToken = await solveCreationCaptcha();
|
||||
onCreated(await create(opts, captchaToken));
|
||||
} catch {
|
||||
setFailed(true);
|
||||
setBusy(false);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { lazy, Suspense, useCallback, useState, type DragEvent } from 'react';
|
||||
import { lazy, Suspense, useCallback, useState } from 'react';
|
||||
import { channelPath, navigate, sharePath } from '@/app/navigation';
|
||||
import type { ChannelInfo, Share } from '@/core-bridge/types';
|
||||
import { ComposeSheet } from '@/features/channel/ComposeSheet';
|
||||
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||
import { useGlobalPaste } from '@/lib/useGlobalPaste';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { useShares } from '@/store/shares';
|
||||
import { solveCreationCaptcha } from '@/core-bridge/captcha';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { ChannelList } from './ChannelList';
|
||||
import { CreateSheet } from './CreateSheet';
|
||||
@@ -21,67 +21,74 @@ export function Home() {
|
||||
const loadShareDemo = useShares((s) => s.loadDemo);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [scanOpen, setScanOpen] = useState(false);
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [shareText, setShareText] = useState('');
|
||||
const [shareFiles, setShareFiles] = useState<File[]>([]);
|
||||
|
||||
const open = (c: ChannelInfo | Share) => navigate('state' in c ? sharePath(c.id) : channelPath(c.id));
|
||||
const open = useCallback((c: ChannelInfo | Share) => navigate('state' in c ? sharePath(c.id) : channelPath(c.id)), []);
|
||||
|
||||
const share = useCallback(
|
||||
async (p: ClipboardPayload) => {
|
||||
if (busy || (p.files.length === 0 && !p.text)) return;
|
||||
if (busy || (p.files.length === 0 && !p.text.trim())) return false;
|
||||
setBusy(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
open(await createDraft(p));
|
||||
const captchaToken = await solveCreationCaptcha();
|
||||
open(await createDraft(p, captchaToken));
|
||||
setShareFiles([]);
|
||||
setShareText('');
|
||||
return true;
|
||||
} catch {
|
||||
setFailed(true);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[busy, createDraft],
|
||||
[busy, createDraft, open],
|
||||
);
|
||||
|
||||
useGlobalPaste(share);
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
if (!e.dataTransfer.types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
void share({ files: [...e.dataTransfer.files], text: '' });
|
||||
}
|
||||
const onClipboard = useCallback((p: ClipboardPayload) => {
|
||||
if (p.files.length > 0) setShareFiles((files) => [...files, ...p.files]);
|
||||
if (p.text) setShareText((text) => text ? `${text}\n${p.text}` : p.text);
|
||||
}, []);
|
||||
useGlobalPaste(onClipboard);
|
||||
|
||||
return (
|
||||
<div className={styles.page} onDragOver={onDragOver} onDragLeave={() => setDragging(false)} onDrop={onDrop}>
|
||||
<div className={styles.page}>
|
||||
<main className={styles.main}>
|
||||
<header className={styles.head}>
|
||||
<h1 className={styles.logo}>WebDrop</h1>
|
||||
<p className={styles.sub}>在自己的设备之间快速传文件和文本,也可以分享给别人。</p>
|
||||
</header>
|
||||
|
||||
<QuickShare busy={busy} dragging={dragging} onShare={share} onCompose={() => setComposeOpen(true)} />
|
||||
<QuickShare
|
||||
busy={busy}
|
||||
text={shareText}
|
||||
files={shareFiles}
|
||||
onTextChange={setShareText}
|
||||
onFilesChange={setShareFiles}
|
||||
onShare={share}
|
||||
/>
|
||||
{failed ? <p role="alert" className={styles.error}>创建分享失败,请重试。</p> : null}
|
||||
|
||||
<ChannelList kind="share" title="我的分享" onOpen={open} />
|
||||
|
||||
<div className={styles.divider}>常驻频道:多台自己的设备之间同步</div>
|
||||
<div className={styles.actions}>
|
||||
<Button icon="edit" onClick={() => setCreateOpen(true)}>新建频道</Button>
|
||||
<Button icon="scan" onClick={() => setScanOpen(true)}>加入频道</Button>
|
||||
</div>
|
||||
<section className={styles.channels} aria-labelledby="channels-title">
|
||||
<div className={styles.sectionHead}>
|
||||
<h2 id="channels-title">设备频道</h2>
|
||||
<p>多台自己的设备之间长期同步</p>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button icon="edit" onClick={() => setCreateOpen(true)}>新建频道</Button>
|
||||
<Button icon="scan" onClick={() => setScanOpen(true)}>加入频道</Button>
|
||||
</div>
|
||||
|
||||
<ChannelList kind="channel" title="我的频道" onOpen={open} />
|
||||
<ChannelList kind="channel" title="我的频道" onOpen={open} />
|
||||
<p className={styles.note}>加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。</p>
|
||||
</section>
|
||||
|
||||
<p className={styles.note}>
|
||||
加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。
|
||||
</p>
|
||||
{import.meta.env.DEV ? (
|
||||
<Button variant="ghost" onClick={() => { loadDemo(); loadShareDemo(); }}>载入演示数据</Button>
|
||||
) : null}
|
||||
@@ -89,11 +96,8 @@ export function Home() {
|
||||
|
||||
{createOpen ? <CreateSheet onClose={() => setCreateOpen(false)} onCreated={open} /> : null}
|
||||
<Suspense fallback={null}>
|
||||
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onJoined={open} /> : null}
|
||||
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onChannel={open} onShare={open} /> : null}
|
||||
</Suspense>
|
||||
{composeOpen ? (
|
||||
<ComposeSheet onClose={() => setComposeOpen(false)} onSubmit={(text) => void share({ files: [], text })} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef } from 'react';
|
||||
import { useRef, useState, type DragEvent } from 'react';
|
||||
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||
import { readClipboard } from '@/lib/clipboard';
|
||||
import { useMediaQuery } from '@/lib/useMediaQuery';
|
||||
@@ -7,54 +7,103 @@ import styles from './QuickShare.module.css';
|
||||
|
||||
interface Props {
|
||||
busy: boolean;
|
||||
dragging: boolean;
|
||||
onShare: (p: ClipboardPayload) => void;
|
||||
onCompose: () => void;
|
||||
text: string;
|
||||
files: File[];
|
||||
onTextChange: (text: string) => void;
|
||||
onFilesChange: (files: File[]) => void;
|
||||
onShare: (payload: ClipboardPayload) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** 快速分享:拖入、粘贴或选择内容,自动创建一个临时加密频道并给出分享链接 */
|
||||
export function QuickShare({ busy, dragging, onShare, onCompose }: Props) {
|
||||
/** 临时分享入口:先收集文本和文件,确认后再建立草稿。 */
|
||||
export function QuickShare({ busy, text, files, onTextChange, onFilesChange, onShare }: Props) {
|
||||
const touch = useMediaQuery('(pointer: coarse)');
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const galleryRef = useRef<HTMLInputElement>(null);
|
||||
const cameraRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const canShare = text.trim().length > 0 || files.length > 0;
|
||||
|
||||
async function paste() {
|
||||
const clip = await readClipboard();
|
||||
if (!clip) return onCompose(); // 无权限时退回手动输入
|
||||
onShare({ files: clip.files, text: clip.files.length > 0 ? '' : clip.text.trim() });
|
||||
if (!clip) return;
|
||||
if (clip.files.length) onFilesChange([...files, ...clip.files]);
|
||||
if (clip.text) onTextChange(text ? `${text}\n${clip.text}` : clip.text);
|
||||
}
|
||||
|
||||
function pick(files: FileList | null) {
|
||||
if (files?.length) onShare({ files: [...files], text: '' });
|
||||
function onDrop(event: DragEvent<HTMLElement>) {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
if (event.dataTransfer.files.length) onFilesChange([...files, ...event.dataTransfer.files]);
|
||||
}
|
||||
|
||||
const buttons: { key: string; label: string; icon: IconName; run: () => void; touchOnly?: boolean }[] = [
|
||||
function pick(list: FileList | null) {
|
||||
if (list?.length) onFilesChange([...files, ...list]);
|
||||
}
|
||||
|
||||
const actions: { key: string; label: string; icon: IconName; run: () => void; touchOnly?: boolean }[] = [
|
||||
{ key: 'file', label: '选择文件', icon: 'file', run: () => fileRef.current?.click() },
|
||||
{ key: 'paste', label: '粘贴', icon: 'paste', run: () => void paste() },
|
||||
{ key: 'write', label: '写文本', icon: 'edit', run: onCompose },
|
||||
{ key: 'gallery', label: '相册', icon: 'image', run: () => galleryRef.current?.click(), touchOnly: true },
|
||||
{ key: 'camera', label: '拍照', icon: 'camera', run: () => cameraRef.current?.click(), touchOnly: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className={`${styles.card} ${dragging ? styles.dragging : ''}`} aria-busy={busy}>
|
||||
<input ref={fileRef} type="file" multiple hidden onChange={(e) => { pick(e.target.files); e.target.value = ''; }} />
|
||||
<input ref={galleryRef} type="file" accept="image/*,video/*" multiple hidden onChange={(e) => { pick(e.target.files); e.target.value = ''; }} />
|
||||
<input ref={cameraRef} type="file" accept="image/*" capture="environment" hidden onChange={(e) => { pick(e.target.files); e.target.value = ''; }} />
|
||||
<section
|
||||
className={`${styles.card} ${dragging ? styles.dragging : ''}`}
|
||||
aria-busy={busy}
|
||||
onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }}
|
||||
onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false); }}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<input ref={fileRef} type="file" multiple hidden onChange={(event) => { pick(event.target.files); event.target.value = ''; }} />
|
||||
<input ref={galleryRef} type="file" accept="image/*,video/*" multiple hidden onChange={(event) => { pick(event.target.files); event.target.value = ''; }} />
|
||||
<input ref={cameraRef} type="file" accept="image/*" capture="environment" hidden onChange={(event) => { pick(event.target.files); event.target.value = ''; }} />
|
||||
|
||||
<h2 className={styles.title}>{busy ? '正在创建分享…' : '快速分享'}</h2>
|
||||
<p className={styles.hint}>
|
||||
{touch ? '选择文件或文本' : '把文件拖到这里,或直接 Ctrl+V 粘贴文本'},做成一个分享列表发给别人下载。完成后内容只读,连你自己也不能再改。
|
||||
</p>
|
||||
<div className={styles.buttons}>
|
||||
{buttons.filter((b) => !b.touchOnly || touch).map((b) => (
|
||||
<button key={b.key} type="button" className={styles.btn} disabled={busy} onClick={b.run}>
|
||||
<Icon name={b.icon} size={20} />
|
||||
<span>{b.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<h2 className={styles.title}>快速分享</h2>
|
||||
<p className={styles.hint}>临时文本直接写在这里,也可以拖入文件。内容封存后只读。</p>
|
||||
</div>
|
||||
<span className={styles.tag}><Icon name="lock" size={13} /> 端到端加密</span>
|
||||
</header>
|
||||
|
||||
<textarea
|
||||
className={styles.editor}
|
||||
aria-label="临时分享文本"
|
||||
placeholder="输入或粘贴要分享的文本…"
|
||||
value={text}
|
||||
onChange={(event) => onTextChange(event.target.value)}
|
||||
rows={4}
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
{files.length > 0 ? (
|
||||
<ul className={styles.files} aria-label="待分享文件">
|
||||
{files.map((file, index) => (
|
||||
<li className={styles.file} key={`${file.name}:${file.size}:${index}`}>
|
||||
<Icon name="file" size={16} />
|
||||
<span>{file.name}</span>
|
||||
<button type="button" aria-label={`移除 ${file.name}`} disabled={busy} onClick={() => onFilesChange(files.filter((_, i) => i !== index))}>
|
||||
<Icon name="close" size={15} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.buttons}>
|
||||
{actions.filter((action) => !action.touchOnly || touch).map((action) => (
|
||||
<button key={action.key} type="button" className={styles.btn} disabled={busy} onClick={action.run}>
|
||||
<Icon name={action.icon} size={18} />
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className={styles.submit} disabled={busy || !canShare} onClick={() => void onShare({ files, text })}>
|
||||
{busy ? '正在创建分享…' : '创建临时分享'}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ function InlineText({ item, onOpen }: { item: TextItem; onOpen: () => void }) {
|
||||
* 所以先放占位,点了才加载,避免滚动列表就把次数用光。
|
||||
*/
|
||||
function InlineVideo({ item }: { item: FileItem }) {
|
||||
const free = item.remaining === null;
|
||||
const free = item.remaining === null && !item.gated;
|
||||
const [play, setPlay] = useState(free);
|
||||
const blob = useBlobUrl(item.id, play);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ function Thumb({ id }: { id: string }) {
|
||||
* 有下载上限的文件(GATED)拉取一次就消耗一次下载,所以不自动拉取缩略图。
|
||||
*/
|
||||
export function Tile({ item, size = 44 }: { item: Item; size?: number }) {
|
||||
const thumb = item.kind === 'file' && item.preview === 'image' && item.state.kind === 'complete' && item.remaining === null;
|
||||
const thumb = item.kind === 'file' && !item.gated && item.preview === 'image' && item.state.kind === 'complete' && item.remaining === null;
|
||||
return (
|
||||
<span className={styles.tile} style={{ width: size, height: size }}>
|
||||
{thumb ? <Thumb id={item.id} /> : <Icon name={iconOf(item)} size={Math.round(size / 2.2)} />}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const channelSource: ItemSource = {
|
||||
useItem: (id) => useWorkspace((s) => s.byId[id]),
|
||||
useDeviceNamer: () => {
|
||||
const devices = useWorkspace((s) => s.devices);
|
||||
return (deviceId) => devices.find((d) => d.id === deviceId)?.name;
|
||||
return (deviceId) => devices.find((d) => d.id === deviceId)?.name ?? deviceId;
|
||||
},
|
||||
getBlob: (id, versionId) => useWorkspace.getState().getBlob(id, versionId),
|
||||
listArchive: (id) => useWorkspace.getState().listArchive(id),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
.empty {
|
||||
display: grid;
|
||||
min-height: 260px;
|
||||
padding: var(--space-6);
|
||||
place-items: center;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.head h2 {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.head p {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
min-height: 280px;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
line-height: 1.65;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.editor:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fileActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.fileActions > span {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.head {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fileActions {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-height: 180px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import type { FileItem, Item, TextItem } from '@/core-bridge/types';
|
||||
import ItemDetail from '@/features/item/ItemDetail';
|
||||
import { formatSize, formatTime } from '@/lib/format';
|
||||
import { useShareSession } from '@/store/shareSession';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './DraftPreview.module.css';
|
||||
|
||||
interface Props {
|
||||
item?: Item;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export function DraftPreview({ item, onSelect }: Props) {
|
||||
if (!item) return <div className={styles.empty}>选择一项内容进行预览或修改。</div>;
|
||||
return item.kind === 'text'
|
||||
? <TextDraft item={item} />
|
||||
: <FileDraft item={item} onSelect={onSelect} />;
|
||||
}
|
||||
|
||||
function TextDraft({ item }: { item: TextItem }) {
|
||||
const [text, setText] = useState(item.text);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const editText = useShareSession((s) => s.editText);
|
||||
const dirty = text !== item.text;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await editText(item.id, text);
|
||||
} catch {
|
||||
setError('保存失败,请重试。');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.preview} aria-label="文本编辑">
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<h2>文本</h2>
|
||||
<p>{formatTime(item.ts)} · {formatSize(new Blob([text]).size)}</p>
|
||||
</div>
|
||||
<Button variant="primary" disabled={!dirty || saving} onClick={() => void save()}>{saving ? '保存中…' : '保存修改'}</Button>
|
||||
</header>
|
||||
<textarea className={styles.editor} aria-label="修改分享文本" value={text} onChange={(event) => setText(event.target.value)} />
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDraft({ item, onSelect }: { item: FileItem; onSelect: (id: string) => void }) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
const addFiles = useShareSession((s) => s.addFiles);
|
||||
const removeItem = useShareSession((s) => s.removeItem);
|
||||
|
||||
async function replace(file: File | undefined) {
|
||||
if (!file || replacing) return;
|
||||
setReplacing(true);
|
||||
const oldIds = new Set(useShareSession.getState().order);
|
||||
try {
|
||||
await addFiles([file]);
|
||||
const state = useShareSession.getState();
|
||||
const newId = state.order.find((id) => !oldIds.has(id));
|
||||
const next = newId ? state.byId[newId] : undefined;
|
||||
if (newId && next?.state.kind === 'complete') {
|
||||
await removeItem(item.id);
|
||||
onSelect(newId);
|
||||
}
|
||||
} finally {
|
||||
setReplacing(false);
|
||||
if (input.current) input.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.preview} aria-label="文件预览">
|
||||
<input ref={input} className={styles.hidden} type="file" onChange={(event) => void replace(event.target.files?.[0])} />
|
||||
<div className={styles.fileActions}>
|
||||
<Button disabled={replacing || item.state.kind === 'receiving'} onClick={() => input.current?.click()}>
|
||||
{replacing ? '正在替换…' : '替换文件'}
|
||||
</Button>
|
||||
<span>替换成功后,原文件会从草稿中移除。</span>
|
||||
</div>
|
||||
<ItemDetail id={item.id} showTitle readOnly onDelete={() => {}} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,35 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4);
|
||||
gap: var(--space-1);
|
||||
min-height: 68px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
animation: row-in 0.21s ease-out;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.select {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
min-height: 56px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.select:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.text {
|
||||
@@ -41,16 +66,3 @@
|
||||
background: var(--progress-gradient);
|
||||
transition: width 0.25s linear;
|
||||
}
|
||||
|
||||
@keyframes row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
translate: 0 -6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.row {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Tile } from '@/features/item/Tile';
|
||||
import styles from './DraftRow.module.css';
|
||||
|
||||
/** 草稿里的一行:可以移除。只在草稿阶段存在 */
|
||||
export function DraftRow({ id }: { id: string }) {
|
||||
export function DraftRow({ id, selected, onSelect }: { id: string; selected: boolean; onSelect: () => void }) {
|
||||
const item = useShareSession((s) => s.byId[id]);
|
||||
const removeItem = useShareSession((s) => s.removeItem);
|
||||
if (!item) return null;
|
||||
@@ -15,17 +15,19 @@ export function DraftRow({ id }: { id: string }) {
|
||||
const size = item.kind === 'file' ? item.size : new Blob([item.text]).size;
|
||||
|
||||
return (
|
||||
<li className={styles.row}>
|
||||
<Tile item={item} />
|
||||
<span className={styles.text}>
|
||||
<span className={styles.title}>{titleOf(item)}</span>
|
||||
<span className={styles.sub}>{formatSize(size)}{receiving !== null ? ` · 传输中 ${Math.round(receiving * 100)}%` : ''}</span>
|
||||
{receiving !== null ? (
|
||||
<span className={styles.bar} role="progressbar" aria-valuenow={Math.round(receiving * 100)}>
|
||||
<span className={styles.fill} style={{ width: `${Math.round(receiving * 100)}%` }} />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<li className={`${styles.row} ${selected ? styles.selected : ''}`}>
|
||||
<button type="button" className={styles.select} aria-pressed={selected} onClick={onSelect}>
|
||||
<Tile item={item} />
|
||||
<span className={styles.text}>
|
||||
<span className={styles.title}>{titleOf(item)}</span>
|
||||
<span className={styles.sub}>{formatSize(size)}{receiving !== null ? ` · 传输中 ${Math.round(receiving * 100)}%` : ''}</span>
|
||||
{receiving !== null ? (
|
||||
<span className={styles.bar} role="progressbar" aria-valuenow={Math.round(receiving * 100)}>
|
||||
<span className={styles.fill} style={{ width: `${Math.round(receiving * 100)}%` }} />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
<Button variant="ghost" icon="close" label="移除" onClick={() => void removeItem(id)} />
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.bar {
|
||||
background: var(--bg);
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
width: min(100%, 640px);
|
||||
width: min(100%, 1120px);
|
||||
min-height: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -31,8 +32,52 @@
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: var(--space-6) var(--space-4);
|
||||
padding: var(--space-4);
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
min-height: 320px;
|
||||
flex: 1;
|
||||
grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.listPane {
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.listTitle {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.listTitle span {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.previewPane {
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list {
|
||||
@@ -80,8 +125,32 @@
|
||||
backdrop-filter: var(--blur-bar);
|
||||
}
|
||||
|
||||
.footer > * {
|
||||
width: min(100%, 640px);
|
||||
.completion {
|
||||
display: grid;
|
||||
width: min(100%, 1120px);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 280px);
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.acknowledgement {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.acknowledgement input {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 1px 0 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.completion > button {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
@@ -94,27 +163,42 @@
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.footer {
|
||||
background: var(--bg);
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
@media (width <= 899px) {
|
||||
.main {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.listPane {
|
||||
max-height: 35vh;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.previewPane {
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm p {
|
||||
margin: 0;
|
||||
}
|
||||
@media (width <= 600px) {
|
||||
.completion {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.confirmActions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
.settings {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import { useGlobalPaste } from '@/lib/useGlobalPaste';
|
||||
import { useShares } from '@/store/shares';
|
||||
import { useShareSession } from '@/store/shareSession';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import { DraftRow } from './DraftRow';
|
||||
import { DraftPreview } from './DraftPreview';
|
||||
import { ShareBar } from './ShareBar';
|
||||
import styles from './ShareDraft.module.css';
|
||||
|
||||
@@ -29,20 +29,25 @@ const DOWNLOADS = [
|
||||
export function ShareDraft() {
|
||||
const share = useShareSession((s) => s.share);
|
||||
const ids = useShareSession((s) => s.order);
|
||||
const error = useShareSession((s) => s.error);
|
||||
const busy = useShareSession((s) => s.order.some((id) => s.byId[id]?.state.kind === 'receiving'));
|
||||
const addFiles = useShareSession((s) => s.addFiles);
|
||||
const addText = useShareSession((s) => s.addText);
|
||||
const updateSettings = useShares((s) => s.updateSettings);
|
||||
const seal = useShares((s) => s.seal);
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [sealing, setSealing] = useState(false);
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [sealError, setSealError] = useState('');
|
||||
const activeId = selectedId && ids.includes(selectedId) ? selectedId : ids[0];
|
||||
const selectedItem = useShareSession((s) => activeId ? s.byId[activeId] : undefined);
|
||||
|
||||
const add = useCallback(
|
||||
(p: ClipboardPayload) => {
|
||||
if (p.files.length > 0) void addFiles(p.files);
|
||||
else if (p.text) void addText(p.text);
|
||||
if (p.text.trim()) void addText(p.text);
|
||||
},
|
||||
[addFiles, addText],
|
||||
);
|
||||
@@ -61,11 +66,13 @@ export function ShareDraft() {
|
||||
async function finish() {
|
||||
if (!share) return;
|
||||
setSealing(true);
|
||||
setSealError('');
|
||||
try {
|
||||
await seal(share.id); // 成功后分享状态变为 sealed,页面自动切换到只读页
|
||||
} catch {
|
||||
setSealError('完成分享失败,草稿仍可修改,请重试。');
|
||||
} finally {
|
||||
setSealing(false);
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,13 +99,23 @@ export function ShareDraft() {
|
||||
onCompose={() => setComposeOpen(true)}
|
||||
/>
|
||||
|
||||
{ids.length === 0 ? (
|
||||
<p className={styles.empty}>还没有内容。拖入文件、粘贴文本,或点上面的按钮。</p>
|
||||
) : (
|
||||
<ul className={styles.list}>
|
||||
{ids.map((id) => <DraftRow key={id} id={id} />)}
|
||||
</ul>
|
||||
)}
|
||||
<div className={styles.content}>
|
||||
<aside className={styles.listPane} aria-label="草稿内容列表">
|
||||
<h2 className={styles.listTitle}>内容清单 <span>{ids.length}</span></h2>
|
||||
{ids.length === 0 ? (
|
||||
<p className={styles.empty}>拖入文件、粘贴文本,或点上方按钮添加。</p>
|
||||
) : (
|
||||
<ul className={styles.list}>
|
||||
{ids.map((id) => (
|
||||
<DraftRow key={id} id={id} selected={id === activeId} onSelect={() => setSelectedId(id)} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
<section className={styles.previewPane} aria-label="选中内容">
|
||||
<DraftPreview item={selectedItem} onSelect={setSelectedId} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className={styles.settings} aria-label="分享设置">
|
||||
<label>
|
||||
@@ -109,7 +126,7 @@ export function ShareDraft() {
|
||||
</label>
|
||||
<label>
|
||||
<span>最多访客</span>
|
||||
<select value={share.seats} onChange={(e) => patch({ seats: Number(e.target.value) })}>
|
||||
<select value={share.maxVisitors} onChange={(e) => patch({ maxVisitors: Number(e.target.value) })}>
|
||||
{SEATS.map((n) => <option key={n} value={n}>{n} 人</option>)}
|
||||
</select>
|
||||
</label>
|
||||
@@ -120,27 +137,23 @@ export function ShareDraft() {
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
{sealError ? <p className={styles.error} role="alert">{sealError}</p> : null}
|
||||
</main>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<Button variant="primary" disabled={ids.length === 0 || busy} onClick={() => setConfirmOpen(true)}>
|
||||
{busy ? '传输中…' : '完成分享'}
|
||||
</Button>
|
||||
<div className={styles.completion}>
|
||||
<label className={styles.acknowledgement}>
|
||||
<input type="checkbox" checked={acknowledged} disabled={sealing} onChange={(event) => setAcknowledged(event.target.checked)} />
|
||||
<span>完成后会生成分享链接和二维码。此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期。</span>
|
||||
</label>
|
||||
<Button variant="primary" disabled={ids.length === 0 || busy || sealing || !acknowledged} onClick={() => void finish()}>
|
||||
{busy ? '传输中…' : sealing ? '正在完成…' : '完成分享'}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{composeOpen ? <ComposeSheet onClose={() => setComposeOpen(false)} onSubmit={(t) => void addText(t)} /> : null}
|
||||
{confirmOpen ? (
|
||||
<Sheet title="完成分享" onClose={() => setConfirmOpen(false)}>
|
||||
<div className={styles.confirm}>
|
||||
<p>完成后会生成分享链接和二维码。<strong>此后任何人(包括你)都不能再添加、修改或删除内容</strong>,只能等它过期。</p>
|
||||
<p className={styles.dim}>发错了也撤不回,请先确认列表。</p>
|
||||
<div className={styles.confirmActions}>
|
||||
<Button variant="primary" disabled={sealing} onClick={() => void finish()}>{sealing ? '正在完成…' : '确认完成'}</Button>
|
||||
<Button disabled={sealing} onClick={() => setConfirmOpen(false)}>返回修改</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Sheet>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,6 +69,13 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -18,13 +18,15 @@ const noop = () => {};
|
||||
export function ShareView() {
|
||||
const share = useShareSession((s) => s.share);
|
||||
const ids = useShareSession((s) => s.order);
|
||||
const integrityError = useShareSession((s) => s.integrityError);
|
||||
const error = useShareSession((s) => s.error);
|
||||
const download = useDownload();
|
||||
const [sheetId, setSheetId] = useState<string>();
|
||||
const [showQr, setShowQr] = useState(true);
|
||||
const closeSheet = useCallback(() => setSheetId(undefined), []);
|
||||
|
||||
if (!share) return null;
|
||||
const remaining = formatRemaining(share.createdAt + share.ttlSeconds * 1000);
|
||||
const remaining = formatRemaining(share.expiresAt);
|
||||
|
||||
async function downloadAll() {
|
||||
const { byId } = useShareSession.getState();
|
||||
@@ -42,6 +44,8 @@ export function ShareView() {
|
||||
badge={<span className={styles.badge}><Icon name="lock" size={12} /> 已封存 · 只读</span>}
|
||||
/>
|
||||
<main className={styles.main}>
|
||||
{integrityError ? <p className={styles.error} role="alert">{integrityError}</p> : null}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
{share.mine ? (
|
||||
<section className={styles.share} aria-label="分享给别人">
|
||||
<h2 className={styles.h}>分享给别人</h2>
|
||||
|
||||
@@ -29,6 +29,9 @@ describe('parseChannelLink', () => {
|
||||
['路径不对', `https://x.example/other/${ID}`, 'bad_url'],
|
||||
['id 长度不对', 'https://x.example/c/abc', 'bad_id'],
|
||||
['密钥长度不对', `https://x.example/c/${ID}#k=short`, 'bad_key'],
|
||||
['密钥误放进查询串', `https://x.example/c/${ID}?k=${KEY}`, 'bad_key'],
|
||||
['管理密钥误放进查询串', `https://x.example/c/${ID}?a=${KEY}`, 'bad_key'],
|
||||
['写密钥误放进查询串', `https://x.example/c/${ID}?w=${KEY}`, 'bad_key'],
|
||||
])('%s → %s', (_n, input, error) => {
|
||||
expect(parseChannelLink(input)).toEqual({ ok: false, error });
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ export function parseChannelLink(input: string): LinkResult {
|
||||
} catch {
|
||||
return { ok: false, error: 'bad_url' };
|
||||
}
|
||||
if (['k', 'a', 'w'].some((name) => url.searchParams.has(name))) return { ok: false, error: 'bad_key' };
|
||||
const m = /^\/c\/([^/]+)\/?$/.exec(url.pathname);
|
||||
if (!m || !m[1] || !ID_RE.test(m[1])) return { ok: false, error: m ? 'bad_id' : 'bad_url' };
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ describe('parseShareLink', () => {
|
||||
['id 长度不对', `https://x.example/s/abc#k=${KEY}`, 'bad_id'],
|
||||
['缺少密钥', `https://x.example/s/${ID}`, 'bad_key'],
|
||||
['密钥长度不对', `https://x.example/s/${ID}#k=short`, 'bad_key'],
|
||||
['密钥误放进查询串', `https://x.example/s/${ID}?k=${KEY}`, 'bad_key'],
|
||||
])('%s → %s', (_n, input, error) => {
|
||||
expect(parseShareLink(input)).toEqual({ ok: false, error });
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ export function parseShareLink(input: string): ShareLinkResult {
|
||||
} catch {
|
||||
return { ok: false, error: 'bad_url' };
|
||||
}
|
||||
if (['k', 'a', 'w'].some((name) => url.searchParams.has(name))) return { ok: false, error: 'bad_key' };
|
||||
const m = /^\/s\/([^/]+)\/?$/.exec(url.pathname);
|
||||
if (!m?.[1]) return { ok: false, error: 'bad_url' };
|
||||
if (!ID_RE.test(m[1])) return { ok: false, error: 'bad_id' };
|
||||
|
||||
@@ -21,3 +21,6 @@ createRoot(document.getElementById('root')!).render(
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
void useChannels.getState().hydrate();
|
||||
void useShares.getState().hydrate();
|
||||
@@ -6,28 +6,32 @@ import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
interface ChannelsState {
|
||||
/** 我的频道(原型:只在内存里,刷新即清空;真实实现存 IndexedDB,architecture §6) */
|
||||
list: ChannelInfo[];
|
||||
hydrated: boolean;
|
||||
/** 从链接进来、还没确认加入的频道;含密钥,只放内存 */
|
||||
pendingLink: ParsedChannelLink | null;
|
||||
/** 刚创建的频道:进入时直接展示分享面板与二维码 */
|
||||
justCreatedId: string | null;
|
||||
|
||||
create: (opts: CreateOptions) => Promise<ChannelInfo>;
|
||||
create: (opts: CreateOptions, captchaToken?: string) => Promise<ChannelInfo>;
|
||||
join: (link: ParsedChannelLink) => Promise<ChannelInfo>;
|
||||
forget: (id: string) => void;
|
||||
hydrate: () => Promise<void>;
|
||||
forget: (id: string) => Promise<void>;
|
||||
loadDemo: () => void;
|
||||
setPendingLink: (link: ParsedChannelLink | null) => void;
|
||||
clearJustCreated: () => void;
|
||||
}
|
||||
|
||||
const upsert = (list: ChannelInfo[], c: ChannelInfo) => [c, ...list.filter((x) => x.id !== c.id)];
|
||||
let hydration: Promise<void> | undefined;
|
||||
|
||||
export const useChannels = create<ChannelsState>()((set) => ({
|
||||
list: [],
|
||||
hydrated: false,
|
||||
pendingLink: null,
|
||||
justCreatedId: null,
|
||||
|
||||
create: async (opts) => {
|
||||
const c = await api.createChannel(opts);
|
||||
create: async (opts, captchaToken) => {
|
||||
const c = await api.createChannel(opts, captchaToken);
|
||||
set((s) => ({ list: upsert(s.list, c), justCreatedId: c.id }));
|
||||
return c;
|
||||
},
|
||||
@@ -36,7 +40,14 @@ export const useChannels = create<ChannelsState>()((set) => ({
|
||||
set((s) => ({ list: upsert(s.list, c), pendingLink: null }));
|
||||
return c;
|
||||
},
|
||||
forget: (id) => set((s) => ({ list: s.list.filter((c) => c.id !== id) })),
|
||||
hydrate: () => {
|
||||
hydration ??= api.listSaved().then((list) => set({ list, hydrated: true }), () => set({ hydrated: true }));
|
||||
return hydration;
|
||||
},
|
||||
forget: async (id) => {
|
||||
await api.forget(id);
|
||||
set((s) => ({ list: s.list.filter((c) => c.id !== id) }));
|
||||
},
|
||||
loadDemo: () => {
|
||||
const demo = api.loadDemo?.() ?? [];
|
||||
set((s) => ({ list: demo.reduce(upsert, s.list) }));
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface WorkspaceData {
|
||||
/** 新的在前 */
|
||||
order: string[];
|
||||
snapshot: Snapshot;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const initialData: WorkspaceData = {
|
||||
@@ -26,6 +27,7 @@ export const initialData: WorkspaceData = {
|
||||
byId: {},
|
||||
order: [],
|
||||
snapshot: { text: '', updatedBy: '', updatedAt: 0 },
|
||||
error: null,
|
||||
};
|
||||
|
||||
function insertByTime(order: string[], byId: Record<string, Item>, item: Item): string[] {
|
||||
@@ -37,6 +39,10 @@ function insertByTime(order: string[], byId: Record<string, Item>, item: Item):
|
||||
|
||||
export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
|
||||
switch (e.t) {
|
||||
case 'reset':
|
||||
return { ...initialData, connection: s.connection };
|
||||
case 'error':
|
||||
return { ...s, error: e.message };
|
||||
case 'ready':
|
||||
return { ...s, ready: true };
|
||||
case 'connection':
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Share, TextItem } from '@/core-bridge/types';
|
||||
import { initialShareData, reduceShare } from './shareReduce';
|
||||
|
||||
const text = (id: string, ts: number): TextItem => ({ id, kind: 'text', ts, deviceId: 'x', state: { kind: 'complete' }, text: id });
|
||||
const share: Share = { id: 's', state: 'draft', mine: true, link: 'l', createdAt: 0, ttlSeconds: 1, seats: 2, maxDownloads: null };
|
||||
const share: Share = { id: 's', state: 'draft', mine: true, link: 'l', createdAt: 0, expiresAt: 86400000, ttlSeconds: 1, maxVisitors: 2, maxDownloads: null };
|
||||
|
||||
describe('reduceShare', () => {
|
||||
it('收到分享信息与就绪', () => {
|
||||
|
||||
@@ -6,9 +6,11 @@ export interface ShareData {
|
||||
byId: Record<string, Item>;
|
||||
/** 新的在前 */
|
||||
order: string[];
|
||||
integrityError: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const initialShareData: ShareData = { ready: false, share: null, byId: {}, order: [] };
|
||||
export const initialShareData: ShareData = { ready: false, share: null, byId: {}, order: [], integrityError: null, error: null };
|
||||
|
||||
function insertByTime(order: string[], byId: Record<string, Item>, item: Item): string[] {
|
||||
const at = order.findIndex((id) => (byId[id]?.ts ?? 0) < item.ts);
|
||||
@@ -34,6 +36,10 @@ export function reduceShare(s: ShareData, e: ShareEvent): ShareData {
|
||||
delete byId[e.id];
|
||||
return { ...s, byId, order: s.order.filter((id) => id !== e.id) };
|
||||
}
|
||||
case 'integrity':
|
||||
return { ...s, byId: {}, order: [], integrityError: e.message };
|
||||
case 'error':
|
||||
return { ...s, error: e.message };
|
||||
default: {
|
||||
const exhaustive: never = e;
|
||||
return exhaustive;
|
||||
|
||||
@@ -6,6 +6,7 @@ interface Actions {
|
||||
/** 打开(订阅)一个分享:先清空上一个的数据,返回取消订阅函数 */
|
||||
open: (shareId: string) => () => void;
|
||||
addText: (text: string) => Promise<void>;
|
||||
editText: (id: string, text: string) => Promise<void>;
|
||||
addFiles: (files: File[]) => Promise<void>;
|
||||
removeItem: (id: string) => Promise<void>;
|
||||
getBlob: (id: string) => Promise<Blob>;
|
||||
@@ -20,6 +21,7 @@ export const useShareSession = create<ShareData & Actions>()(() => ({
|
||||
return shareApi.connect(shareId, (e) => useShareSession.setState((s) => reduceShare(s, e)));
|
||||
},
|
||||
addText: (t) => shareApi.addText(t),
|
||||
editText: (id, text) => shareApi.editText(id, text),
|
||||
addFiles: (f) => shareApi.addFiles(f),
|
||||
removeItem: (id) => shareApi.removeItem(id),
|
||||
getBlob: (id) => shareApi.getBlob(id),
|
||||
|
||||
+17
-6
@@ -5,41 +5,49 @@ import type { ClipboardPayload } from '@/lib/clipboard';
|
||||
import type { ParsedShareLink } from '@/lib/shareLink';
|
||||
|
||||
/** 便捷分享的默认设置:最多 10 位访客、24 小时、每个文件最多下载 10 次 */
|
||||
export const SHARE_DEFAULTS: ShareSettings = { ttlSeconds: 86400, seats: 10, maxDownloads: 10 };
|
||||
export const SHARE_DEFAULTS: ShareSettings = { ttlSeconds: 86400, maxVisitors: 10, maxDownloads: 10 };
|
||||
|
||||
interface SharesState {
|
||||
/** 我的分享:自己创建的(草稿、已封存)和收到的。原型只在内存里 */
|
||||
list: Share[];
|
||||
hydrated: boolean;
|
||||
/** 从链接进来、还没加入的分享;含密钥,只放内存 */
|
||||
pendingLink: ParsedShareLink | null;
|
||||
/** 便捷分享:新建草稿时带来的内容,进入草稿页后放进去 */
|
||||
pendingPayload: ClipboardPayload | null;
|
||||
|
||||
/** 新建草稿(可带上要放进去的内容) */
|
||||
createDraft: (payload?: ClipboardPayload) => Promise<Share>;
|
||||
createDraft: (payload?: ClipboardPayload, captchaToken?: string) => Promise<Share>;
|
||||
hydrate: () => Promise<void>;
|
||||
updateSettings: (id: string, patch: Partial<ShareSettings>) => Promise<Share>;
|
||||
/** 完成分享:此后无人可改 */
|
||||
seal: (id: string) => Promise<Share>;
|
||||
/** 访客加入:直接进入,不预览 */
|
||||
join: (link: ParsedShareLink) => Promise<Share>;
|
||||
forget: (id: string) => void;
|
||||
forget: (id: string) => Promise<void>;
|
||||
loadDemo: () => void;
|
||||
setPendingLink: (link: ParsedShareLink | null) => void;
|
||||
consumePayload: () => ClipboardPayload | null;
|
||||
}
|
||||
|
||||
const upsert = (list: Share[], s: Share) => [s, ...list.filter((x) => x.id !== s.id)];
|
||||
let hydration: Promise<void> | undefined;
|
||||
|
||||
export const useShares = create<SharesState>()((set, get) => ({
|
||||
list: [],
|
||||
hydrated: false,
|
||||
pendingLink: null,
|
||||
pendingPayload: null,
|
||||
|
||||
createDraft: async (payload) => {
|
||||
const s = await shareApi.createDraft(SHARE_DEFAULTS);
|
||||
createDraft: async (payload, captchaToken) => {
|
||||
const s = await shareApi.createDraft(SHARE_DEFAULTS, captchaToken);
|
||||
set((st) => ({ list: upsert(st.list, s), pendingPayload: payload ?? null }));
|
||||
return s;
|
||||
},
|
||||
hydrate: () => {
|
||||
hydration ??= shareApi.listSaved().then((list) => set({ list, hydrated: true }), () => set({ hydrated: true }));
|
||||
return hydration;
|
||||
},
|
||||
updateSettings: async (id, patch) => {
|
||||
const s = await shareApi.updateSettings(id, patch);
|
||||
set((st) => ({ list: upsert(st.list, s) }));
|
||||
@@ -55,7 +63,10 @@ export const useShares = create<SharesState>()((set, get) => ({
|
||||
set((st) => ({ list: upsert(st.list, s), pendingLink: null }));
|
||||
return s;
|
||||
},
|
||||
forget: (id) => set((st) => ({ list: st.list.filter((x) => x.id !== id) })),
|
||||
forget: async (id) => {
|
||||
await shareApi.forget(id);
|
||||
set((st) => ({ list: st.list.filter((x) => x.id !== id) }));
|
||||
},
|
||||
loadDemo: () => {
|
||||
const demo = shareApi.loadDemo?.() ?? [];
|
||||
set((st) => ({ list: demo.reduce(upsert, st.list) }));
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
"paths": { "@/*": ["./src/*"], "@webdrop/core": ["../packages/core/src/index.ts"] }
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user