22 Commits
Author SHA1 Message Date
justme 937b774a93 test(web): cover workspace and file interaction workflows
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-10-13 01:39:26 +08:00
justme c2ea1b7f64 docs: consolidate account workspace architecture 2026-10-13 01:39:25 +08:00
justme b65e32885f feat(auth): remember encrypted local login credentials 2026-10-13 01:39:24 +08:00
justme f6cfb12716 feat(web): add spaces, grouped channels and file chat 2026-10-13 01:39:23 +08:00
justme c7d8ef15bc feat(api): bind encrypted workspaces to accounts and devices 2026-10-13 01:38:19 +08:00
justme 019b15041a build(deploy): switch hosting to Podman Quadlet 2026-10-13 01:38:18 +08:00
justme 10aa0304e4 feat(web): separate build sources and localize time
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-10-12 16:50:15 +08:00
justme 89cd92f7ee feat(web): show build info and theme menu [CI]
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-12 16:40:50 +08:00
justme 98a5e67f82 ci: reuse NativeAOT build image [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-12 02:09:50 +08:00
justme b9c620304c fix(ci): stabilize oversized body check [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was canceled
2026-10-12 01:58:41 +08:00
justme 9f02a17c85 ci: align Zot login with MoShiro [CI]
ci/woodpecker/push/web-build Pipeline failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/dev-image unknown status
2026-10-12 01:56:08 +08:00
justme d570435236 ci: use Zot API key at registry.oeo.one [CI]
ci/woodpecker/push/web-build Pipeline failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/dev-image unknown status
2026-10-12 01:54:25 +08:00
justme fd01120d3a ci: gate development images on checks [CI] 2026-10-12 01:47:24 +08:00
justme c0d155c608 fix: stabilize mobile uploads and show transfer speed 2026-10-03 01:37:43 +08:00
justme 09d66092f5 ci: fix latest image tag publishing commands
ci/woodpecker/push/ci Pipeline failed
2026-10-02 06:21:04 +00:00
justme ca62b9dd6e ci: publish latest image tag on release
ci/woodpecker/push/ci Pipeline failed
2026-10-02 06:20:05 +00:00
justme c68acfd78b fix: allow embedded worker API requests
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/tag/release-backend Pipeline was successful
2026-10-02 14:12:14 +08:00
justme 10ea48bf04 fix: clarify quick share creation failures
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/tag/release-backend Pipeline was successful
2026-10-02 13:44:03 +08:00
justme fbe6abd519 feat: embed frontend in backend
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/tag/release-backend Pipeline was successful
2026-10-02 13:11:10 +08:00
justme 22f9bcfa46 ci: reuse Buildah layers for backend releases
ci/woodpecker/push/ci Pipeline was successful
2026-10-02 05:02:03 +00:00
justme 46974b120e ci: cache and prefetch WebDrop dependencies
ci/woodpecker/tag/release-backend Pipeline was successful
2026-10-02 04:18:48 +00:00
justme 3332e7bbcf ci: cache and prefetch WebDrop dependencies 2026-10-02 04:18:41 +00:00
202 changed files with 7833 additions and 1014 deletions

No files matched your search

View File
File renamed without changes.
+1 -1
View File
@@ -1,7 +1,7 @@
# API only. Loopback binding lets a local frontend or host reverse proxy reach it. # API only. Loopback binding lets a local frontend or host reverse proxy reach it.
API_BIND_ADDRESS=127.0.0.1 API_BIND_ADDRESS=127.0.0.1
API_PORT=5000 API_PORT=5000
WEBDROP_IMAGE_REPO=zot.oeo.one/justme/webdrop-api WEBDROP_IMAGE_REPO=registry.oeo.one/justme/webdrop-api
WEBDROP_IMAGE_TAG=local WEBDROP_IMAGE_TAG=local
WEBDROP_REQUESTS_PER_MINUTE=600 WEBDROP_REQUESTS_PER_MINUTE=600
+2
View File
@@ -21,6 +21,8 @@ server/.idea/
!.env.example !.env.example
# Editor and OS files # Editor and OS files
.idea/
.playwright-mcp/
.vscode/ .vscode/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
+16 -22
View File
@@ -1,5 +1,10 @@
when: when:
- event: [push, manual] - event: pull_request
- event: push
branch: master
- event: push
branch: main
- event: manual
labels: labels:
ci: domain-linux-native ci: domain-linux-native
@@ -14,32 +19,21 @@ clone:
lfs: false lfs: false
steps: steps:
prefetch-dependencies:
image: bash
commands:
- mkdir -p "$HOME/.cache/woodpecker/webdrop/nuget"
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet restore server/WebDrop.slnx
api-contract: api-contract:
image: bash image: bash
commands: commands:
- node scripts/check-api-contract.mjs - node scripts/check-api-contract.mjs
core-and-web:
image: bash
commands:
- cd packages/core
- npm ci
- npm run typecheck
- npm test
- npm run vectors:check
- npm run build
web:
image: bash
commands:
- cd web
- npm ci
- npm run typecheck
- npm run lint:css
- npm test
- npm run build
server: server:
image: bash image: bash
depends_on: [prefetch-dependencies]
when:
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
commands: commands:
- dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal - NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal --no-restore
+51
View File
@@ -0,0 +1,51 @@
when:
- event: push
branch: master
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
- event: push
branch: main
evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"'
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: woodpeckerci/plugin-git:2.9.3
settings:
remote:
from_secret: gitea_clone_webdrop
lfs: false
depends_on:
- ci
- web-build
concurrency: 1
steps:
publish-dev-image:
image: bash
environment:
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
ZOT_REGISTRY: registry.oeo.one
commands:
- set -euo pipefail
- ': "${WOODPECKER_BACKEND_LOCAL_TEMP_DIR:=$HOME/woodpecker/work}"'
- buildah_store="$(dirname "$WOODPECKER_BACKEND_LOCAL_TEMP_DIR")/buildah-cache/webdrop"
- mkdir -p "$buildah_store/storage" "$buildah_store/run"
- 'buildah() { command buildah --root "$buildah_store/storage" --runroot "$buildah_store/run" "$@"; }'
- printf '%s' "$ZOT_SECRET" | buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$ZOT_REGISTRY"
- trap 'buildah logout "$ZOT_REGISTRY" >/dev/null 2>&1 || true' EXIT
- sha="${CI_COMMIT_SHA:0:7}"
- build_tag="dev-$sha"
- image="$ZOT_REGISTRY/justme/webdrop-api:dev-$sha"
- dev_image="$ZOT_REGISTRY/justme/webdrop-api:dev"
- build_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- buildah bud --layers --network host --format oci --platform linux/amd64 --build-arg "WEBDROP_FRONTEND_COMMIT=$CI_COMMIT_SHA" --build-arg "WEBDROP_FRONTEND_TAG=$build_tag" --build-arg "WEBDROP_BACKEND_COMMIT=$CI_COMMIT_SHA" --build-arg "WEBDROP_BACKEND_TAG=$build_tag" --build-arg "WEBDROP_BUILD_TIME=$build_time" --tag "$image" --file server/Containerfile .
- buildah tag "$image" "$dev_image"
- buildah push "$image" "docker://$image"
- buildah push "$dev_image" "docker://$dev_image"
+17 -9
View File
@@ -32,16 +32,18 @@ steps:
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}" - release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
- test -n "$release_tag" - test -n "$release_tag"
- node scripts/check-api-contract.mjs "$release_tag" - node scripts/check-api-contract.mjs "$release_tag"
- dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal - bash scripts/ci-frontend.sh
- mkdir -p "$HOME/.cache/woodpecker/webdrop/nuget"
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet restore server/WebDrop.slnx
- NUGET_PACKAGES="$HOME/.cache/woodpecker/webdrop/nuget" dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal --no-restore
publish-backend: publish-backend:
image: bash image: bash
environment: environment:
ZOT_USERNAME: ZOT_USERNAME: justme
from_secret: registry_username ZOT_SECRET:
ZOT_PASSWORD: from_secret: zot_secret
from_secret: registry_password ZOT_REGISTRY: registry.oeo.one
ZOT_REGISTRY: 192.168.2.162:5000
commands: commands:
- set -euo pipefail - set -euo pipefail
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}" - release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
@@ -50,8 +52,14 @@ steps:
- buildah_store="$(dirname "$WOODPECKER_BACKEND_LOCAL_TEMP_DIR")/buildah-cache/webdrop" - buildah_store="$(dirname "$WOODPECKER_BACKEND_LOCAL_TEMP_DIR")/buildah-cache/webdrop"
- mkdir -p "$buildah_store/storage" "$buildah_store/run" - mkdir -p "$buildah_store/storage" "$buildah_store/run"
- 'buildah() { command buildah --root "$buildah_store/storage" --runroot "$buildah_store/run" "$@"; }' - 'buildah() { command buildah --root "$buildah_store/storage" --runroot "$buildah_store/run" "$@"; }'
- printf '%s' "$ZOT_PASSWORD" | buildah login --tls-verify=false "$ZOT_REGISTRY" --username "$ZOT_USERNAME" --password-stdin - printf '%s' "$ZOT_SECRET" | buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$ZOT_REGISTRY"
- trap 'buildah logout "$ZOT_REGISTRY" >/dev/null 2>&1 || true' EXIT - trap 'buildah logout "$ZOT_REGISTRY" >/dev/null 2>&1 || true' EXIT
- image="$ZOT_REGISTRY/justme/webdrop-api:$release_tag" - image="$ZOT_REGISTRY/justme/webdrop-api:$release_tag"
- buildah bud --network host --format docker --platform linux/amd64 --tag "$image" --file server/Dockerfile . - latest_image="$ZOT_REGISTRY/justme/webdrop-api:latest"
- buildah push --tls-verify=false "$image" "docker://$image" - build_commit="$(git rev-parse HEAD)"
- build_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- buildah bud --layers --network host --format oci --platform linux/amd64 --build-arg "WEBDROP_FRONTEND_COMMIT=$build_commit" --build-arg "WEBDROP_FRONTEND_TAG=$release_tag" --build-arg "WEBDROP_BACKEND_COMMIT=$build_commit" --build-arg "WEBDROP_BACKEND_TAG=$release_tag" --build-arg "WEBDROP_BUILD_TIME=$build_time" --tag "$image" --file server/Containerfile .
- buildah tag "$image" "$latest_image"
- buildah push "$image" "docker://$image"
- buildah push "$latest_image" "docker://$latest_image"
+25
View File
@@ -0,0 +1,25 @@
when:
- event: pull_request
- event: push
branch: master
- event: push
branch: main
- event: manual
labels:
ci: domain-linux-native
platform: linux/amd64
clone:
- name: git
image: woodpeckerci/plugin-git:2.9.3
settings:
remote:
from_secret: gitea_clone_webdrop
lfs: false
steps:
web-build:
image: bash
commands:
- bash scripts/ci-frontend.sh
+3 -3
View File
@@ -10,7 +10,7 @@
|---|---| |---|---|
| 协议、API、威胁模型 | 已定稿(`docs/`) | | 协议、API、威胁模型 | 已定稿(`docs/`) |
| `test-vectors/` | 已完成。由 `node:crypto` 独立生成,服务端(.NET)和 `@webdrop/core`(WebCrypto)各自复现 | | `test-vectors/` | 已完成。由 `node:crypto` 独立生成,服务端(.NET)和 `@webdrop/core`(WebCrypto)各自复现 |
| `server/` | 频道与便捷分享 API 已实现并通过 132 个测试;win-x64 与 linux-x64 NativeAOT 发布此前已验证零警告 | | `server/` | 频道与便捷分享 API 已实现;含独立的可选 `WebDrop.Frontend` 静态资源模块;NativeAOT 发布为 OCI 镜像 |
| `packages/core/` | 已完成并验证。168 个测试,含对着真实服务端的端到端 | | `packages/core/` | 已完成并验证。168 个测试,含对着真实服务端的端到端 |
| `web/` | 前端,开发中 | | `web/` | 前端,开发中 |
@@ -27,7 +27,7 @@
| [web/AGENTS.md](web/AGENTS.md) | 前端契约:权威顺序、范围、必须遵守与禁止项 | | [web/AGENTS.md](web/AGENTS.md) | 前端契约:权威顺序、范围、必须遵守与禁止项 |
| [packages/core/README.md](packages/core/README.md) | `@webdrop/core` 的接口、前端要做和不用做的事、必须注意的几点 | | [packages/core/README.md](packages/core/README.md) | `@webdrop/core` 的接口、前端要做和不用做的事、必须注意的几点 |
| [server/README.md](server/README.md) | 服务端的结构、配置、AOT 发布、验证码(Cap)接入 | | [server/README.md](server/README.md) | 服务端的结构、配置、AOT 发布、验证码(Cap)接入 |
| [docs/deployment.md](docs/deployment.md) | Docker Compose 自托管部署与 Woodpecker CI | | [docs/deployment.md](docs/deployment.md) | Podman Quadlet 自托管部署与 Woodpecker CI |
| [test-vectors/README.md](test-vectors/README.md) | 协议测试向量的生成与使用 | | [test-vectors/README.md](test-vectors/README.md) | 协议测试向量的生成与使用 |
## 核心概念 ## 核心概念
@@ -45,6 +45,6 @@ webdrop-proj/
├─ docs/ ├─ docs/
├─ test-vectors/ 协议测试向量,所有实现共用 ├─ test-vectors/ 协议测试向量,所有实现共用
├─ packages/core/ @webdrop/core,TS,加解密与帧编解码(网页、将来的 CLI 共用) ├─ packages/core/ @webdrop/core,TS,加解密与帧编解码(网页、将来的 CLI 共用)
├─ server/ .NET 10,NativeAOT ├─ server/ .NET 10,NativeAOT;可将 Vite dist 嵌入 WebDrop.Frontend
└─ web/ Vite + React + TypeScript └─ web/ Vite + React + TypeScript
``` ```
-32
View File
@@ -1,32 +0,0 @@
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
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"version": "1.0.0" "version": "2.1.0"
} }
@@ -0,0 +1,4 @@
[Timer]
OnCalendar=
OnBootSec=1min
OnUnitActiveSec=5min
+26
View File
@@ -0,0 +1,26 @@
[Unit]
Description=WebDrop development environment from registry
[Container]
ContainerName=webdrop-dev
Image=registry.oeo.one/justme/webdrop-api:dev
Pull=missing
AutoUpdate=registry
PublishPort=127.0.0.1:5001:5000
Environment=ASPNETCORE_URLS=http://+:5000
Environment=WebDrop__DataDir=/data
Environment=DOTNET_EnableDiagnostics=0
Volume=%h/podman/webdrop-dev/data:/data:Z,U
ReadOnly=true
ReadOnlyTmpfs=false
Tmpfs=/tmp:rw,noexec,nosuid,size=16m
DropCapability=ALL
NoNewPrivileges=true
[Service]
Restart=always
TimeoutStartSec=120
TimeoutStopSec=60
[Install]
WantedBy=default.target
+25
View File
@@ -0,0 +1,25 @@
[Unit]
Description=WebDrop API with embedded frontend
[Container]
ContainerName=webdrop
Image=localhost/webdrop:local
Pull=never
PublishPort=127.0.0.1:5000:5000
Environment=ASPNETCORE_URLS=http://+:5000
Environment=WebDrop__DataDir=/data
Environment=DOTNET_EnableDiagnostics=0
Volume=/home/justme/podman/webdrop/data:/data:Z,U
ReadOnly=true
ReadOnlyTmpfs=false
Tmpfs=/tmp:rw,noexec,nosuid,size=16m
DropCapability=ALL
NoNewPrivileges=true
[Service]
Restart=always
TimeoutStartSec=120
TimeoutStopSec=60
[Install]
WantedBy=default.target
+39
View File
@@ -0,0 +1,39 @@
# 账号、设备、频道与加密的统一模型
本文件收束账号频道工作区的模型;密码学细节仍以 protocol.md 为准。临时 Share 是独立的对外发布功能,只复用文件传输和密码学,不作为频道成员模型。
## 领域边界
Account 使用不可修改的 username 作为当前版本稳定身份,displayName/avatar 为可修改的个人资料。AccountSettings 与资料在加密保险库中同步。AccountDevice 是账号下的一个浏览器安装身份,具有独立登录 session、名称与撤销状态;每个频道的设备 S 继续独立生成。ChannelMember 绑定账号与频道角色,不绑定某一次登录或某个设备。
Channel 是成员权限和内容加密的共同边界。Thread 属于 Channel,Folder 属于 Thread,文件与消息必须属于 Thread。Thread 不再创建第二套成员、密钥或网络连接;当前频道成员可读取该频道所有 thread。创建频道时自动建立“常规”thread,用户可继续创建其他 thread,分别上传文件或发消息。
## 模块互通
账号模块提供当前身份、个人资料、设备会话与解锁后的账号密钥。频道模块负责创建/邀请/接受/打开频道、thread 和文件目录。加密模块不读取 UI 或数据库,只提供账号密钥封装、频道凭据封装和帧加解密。Worker 是三者的编排入口,组件经 store/action 调用它。
账号登录授权访问服务端;频道内容密钥授权解密内容。这两种能力分开校验,服务端不能通过登录 token 推出频道密钥。频道主的成员记录才是 owner 权限的最终依据,不能凭复制管理密钥冒充另一个账号。
## 密钥与多设备
账号邀请私钥保存在密码加密的保险库中,多台设备登录同一账号后解锁同一账号私钥。频道创建时生成独立随机 K 和管理密钥 A;创建者将 owner 凭据封装给自己,服务端原子存储频道、owner 成员与该账号密钥包。邀请其他账号时仅封装 member 凭据,不能交付 A。密钥包绑定 recipient、channelId 和 mode;服务端保存密文,收件账号自动领取并在本机解密。
已加入成员的新设备只凭账号登录即可恢复频道密钥,不需要邀请者在线,不需要复制带密钥的链接。每个浏览器有独立设备登录凭据和独立频道 S;读取账号频道列表不占席位,实际打开频道才为设备加入席位。设备撤销会撤销其账号 sessions;账号频道成员身份保留,其他设备继续访问。
本机记住登录保存 session token、不可导出的保险库 CryptoKey 和加密保险库,不保存密码/verifier。它允许本机用户解锁此账号;退出删除该记录。登录态与本机资源数据库在同一启动屏障后恢复,避免先加载访客资源再切换账号。
## 文件目录与内容
thread/文件夹结构以及文件目录记录使用频道加密日志。上传文件先完成密文上传,再发布加密 file.published 目录记录,包含文件 ID、thread/folder、名称、类型、大小;成员同步目录无需下载文件内容,也不消耗下载次数。目录与文件内容中的元数据必须相互匹配,否则拒绝展示。
频道 K 经 HKDF 派生内容根与每文件独立 AES-GCM 密钥;保留现有逐帧认证、FINAL、连续性、padding、ID/AAD 校验与持久化密文重试。下载逐块解密到本机临时存储,完整性和大小校验成功才交付文件;出错清除临时内容。OPFS 不可用时使用 Blob 回退并明确限制。
## 安全边界
共享频道密钥意味着任何成员都能解密历史,不能通过移除成员收回其已持有的 K 或明文。当前不实现密钥 epoch 轮换或前向保密,不能声称移除成员可阻止恶意服务端向其提供将来密文。服务端成员/设备撤销负责正常服务下的访问控制。
首次邀请公钥来自服务端目录,必须如实保留公钥替换风险;AES-GCM 认证证明密文未被未持钥者篡改,不证明具体发送账号。逐账号签名、密钥透明性与密钥轮换属于单独的协议升级,不能以补齐账号功能代替这些保证。前端代码与运行终端仍是信任根。
账号昵称与设备名是不同概念,昵称不参与密钥派生。头像仅接受有大小上限的 PNG/JPEG/WebP,保存在加密资料中,禁止第三方头像 URL 和可执行 SVG。
界面术语采用「空间 → 频道分组 → 频道 → 文件夹/聊天」。内部 Channel/Thread 命名保留,分别映射空间/频道;分组沿用加密 CONTROL 日志,不引入新的成员或密钥体系。
+44 -8
View File
@@ -1,4 +1,6 @@
# API v1 # API 契约 2.1
URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `2.1.0`。旧主版本客户端会被契约预检拒绝。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;公开链接仍允许访客按频道加入规则访问。
基础路径 `/api/v1`。所有请求和响应的二进制数据格式见 [protocol.md](protocol.md)。前端托管在独立域名,服务端需要配置 CORS 白名单。 基础路径 `/api/v1`。所有请求和响应的二进制数据格式见 [protocol.md](protocol.md)。前端托管在独立域名,服务端需要配置 CORS 白名单。
@@ -10,8 +12,8 @@
| `Authorization` | `Bearer <base64url(令牌)>` | | `Authorization` | `Bearer <base64url(令牌)>` |
| `X-WD-Endpoint` | `base64url(S)`,终端密钥,用于席位 | | `X-WD-Endpoint` | `base64url(S)`,终端密钥,用于席位 |
| `X-WD-Cap` | 创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11) | | `X-WD-Cap` | 创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11) |
| `X-WD-Label` | raw 模式条目标签,≤255 B |
| `X-WD-Max-Downloads` | 第 0 帧上带,条目的下载上限,0 为不限 | | `X-WD-Max-Downloads` | 第 0 帧上带,条目的下载上限,0 为不限 |
| `X-WD-Account` | 账号 session token。与频道/分享的能力令牌分开;创建、管理和账号成员请求必填,访客省略 |
raw 模式的读请求不需要令牌,但仍需要 `X-WD-Endpoint`。 raw 模式的读请求不需要令牌,但仍需要 `X-WD-Endpoint`。
@@ -28,17 +30,32 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
## 3. 接口 ## 3. 接口
### 账号与保险库
用户名为 3–32 位 ASCII 字母、数字、`_` 或 `-`,大小写不敏感。客户端 PBKDF2-SHA256(600,000 次)派生口令根,再分别派生登录凭据和 AES-GCM 保险库密钥。登录凭据仅经 HTTPS 发送;服务端再用随机 salt 和 PBKDF2-SHA256(210,000 次)存储慢哈希,数据库里的哈希不能直接用于登录。服务端不接收口令根或保险库密钥。二进制字段使用标准 Base64,保险库密文最大 1.3 MB。
- `POST /api/v1/accounts` 注册 `{username,salt,verifier,vault,public_key}`。public_key 为 P-256 ECDH 公钥 JWK 的 JSON 字符串;私钥只在客户端加密保险库中保存。
- `POST /api/v1/accounts/salt` 请求 `{username}`,返回 `{salt}`,供客户端派生登录凭据。
- `POST /api/v1/accounts/session` 请求 `{username,verifier,device_id?,device_name?}`。成功返回 `{username,token}`;session token 随机生成,只保存 SHA-256,期限 30 天。
- `GET /api/v1/accounts/vault` 使用 `Authorization: Bearer <token>` 读取 `{revision,vault}`。
- `PUT /api/v1/accounts/vault` 使用同一鉴权,发送 `{revision,vault}`。revision 乐观并发控制;冲突返回 `409 vault_conflict`,客户端必须先读取并合并,不能静默覆盖。
- `DELETE /api/v1/accounts/session` 撤销当前 bearer session;退出登录时调用。
- `GET /api/v1/accounts/users/{username}/key` 已登录用户查询收件账号公开邀请密钥。
- `GET /api/v1/accounts/invitations` 返回当前账号的邀请,payload 为绑定收件用户名和频道 ID 的 ECDH + HKDF + AES-GCM 密文。
没有邮箱找回流程。忘记密码后保险库无法恢复。保险库仅同步资源凭据和草稿清单,不同步设备 S、设备名、缓存、原始文件或发送队列。浏览器存储按账号与访客分库;切换账号会中止订阅并清空内存会话。资源清单移除使用带时间戳的墓碑同步,避免旧设备将条目恢复。
### `GET /api/v1/info` ### `GET /api/v1/info`
返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。 返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。
### `POST /api/v1/channels` ### `POST /api/v1/channels`
创建频道。服务端启用验证码时需要 `X-WD-Cap`;验证服务不可用时返回 `503 captcha_unavailable`,与「用户没通过验证」(`428 captcha_required`)区分。 创建者必须登录。只有一套频道/成员/线程模型,`mode=enc|raw` 仅决定客户端内容是否端到端加密,不改变业务权限。创建者自动写入该频道的 owner 成员记录并占一个设备席位。服务端启用验证码时还需要 `X-WD-Cap`。
```json ```json
{ {
"id": "<channel_id>", "id": "<channel_id>",
"mode": "enc", "mode": "enc",
"settings": { "seats": 2, "members_can_upload": true, "delete_policy": "own", "settings": { "name": "工作设备", "seats": 2, "members_can_upload": true, "delete_policy": "own",
"ttl_seconds": 86400, "max_items": 1000, "file_max_downloads": 10 }, "ttl_seconds": 86400, "max_items": 1000, "file_max_downloads": 10 },
"read_hash": "<sha256(T_read)>", "read_hash": "<sha256(T_read)>",
"write_hash": "<sha256(T_write)>", "write_hash": "<sha256(T_write)>",
@@ -49,13 +66,19 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创建者的终端(`X-WD-Endpoint`)自动入席。返回 `201`,响应体与「读取频道信息」相同(`ChannelInfo`)。 raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创建者的终端(`X-WD-Endpoint`)自动入席。返回 `201`,响应体与「读取频道信息」相同(`ChannelInfo`)。
### `GET /api/v1/channels/{id}` ### `GET /api/v1/channels/{id}`
返回 `ChannelInfo`:设置、模式、席位占用、`oldest_seq`、`last_seq`、时间。**不占席位**,任意角色可调用,用于加入前预览和核对模式。 返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、席位占用、`oldest_seq`、`last_seq`、时间。**不占席位**,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。
### `POST /api/v1/channels/{id}/seats` ### `POST /api/v1/channels/{id}/seats`
加入频道,占用一个席位。幂等:同一终端重复调用返回同一席位。席位已满返回 `403 seats_full`。读取频道信息和 owner 管理接口不占席位;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 `403 not_joined`。 加入频道,占用一个席位。幂等:同一终端重复调用返回同一席位。席位已满返回 `403 seats_full`。读取频道信息和 owner 管理接口不占席位;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 `403 not_joined`。
可发送 JSON `{password}`。默认 `join_policy=open,allow_guests=true`,允许持链接的陌生人设备加入。`allow_guests=false` 要求账号登录;`join_policy=invite` 要求该账号已受邀。频道主、已加入的账号成员和受邀账号无需再次输入频道密码。密码由服务端保存加盐 PBKDF2 哈希,从不通过频道信息返回。成员账号和设备身份独立:同一账号可占多个独立设备席位;账号成员的读写请求必须出示对应账号 session 和该设备 S。
`GET /channels/{id}/members` 返回频道内账号成员及 `owner|member` 角色。`GET/POST /channels/{id}/invitations` 由频道主查询/发送 `{username,payload}` 邀请;payload 只含客户端加密的成员链接。`DELETE /channels/{id}/invitations/{username}` 撤销邀请、成员授权与该账号的设备席位,并阻止重新加入,直到再次邀请。不能移除频道主。设备面板踢出访客也会阻止该 S 重新加入。
频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号或访客设备、频道成员/席位与写入权限,不能靠客户端隐藏按钮绕过。WebSocket 账号 session 经 `wd.account.<base64url(token)>` 子协议传递,并在推送循环中重新检查有效性。
### `PATCH /api/v1/channels/{id}/settings` ### `PATCH /api/v1/channels/{id}/settings`
所有者修改设置。`ttl_seconds` 的修改从现在起算。 所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`ttl_seconds` 的修改从现在起算。
### `DELETE /api/v1/channels/{id}` ### `DELETE /api/v1/channels/{id}`
所有者删除整个频道和全部密文。 所有者删除整个频道和全部密文。
@@ -96,8 +119,9 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
### 席位 ### 席位
- `GET /api/v1/channels/{id}/seats`:所有者查看(终端哈希、加密设备名、加入时间)。 - `GET /api/v1/channels/{id}/seats`:所有者查看(终端哈希、加密设备名、加入时间)。
- `PUT /api/v1/channels/{id}/seats/me`:终端上传加密设备名(≤256 B)。 - `PUT /api/v1/channels/{id}/seats/me`:终端上传设备名(≤256 B);enc 模式加密,raw 模式明文。
- `DELETE /api/v1/channels/{id}/seats/{endpoint_hash}`:所有者释放席位。 - `PUT /api/v1/channels/{id}/seats/{endpoint_hash}/label`:所有者为指定设备更新名称;enc 模式的密文绑定目标 endpoint hash,raw 模式明文。设备也可用 `/seats/me` 更新自己的名称。
- `DELETE /api/v1/channels/{id}/seats/{endpoint_hash}`:所有者移除该设备的席位,其活动订阅随后会被拒绝。
## 4. 便捷分享接口 ## 4. 便捷分享接口
@@ -189,3 +213,15 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
| `captcha_required` | 428 | 需要验证码:缺少 `X-WD-Cap`,或 token 无效、已用过 | | `captcha_required` | 428 | 需要验证码:缺少 `X-WD-Cap`,或 token 无效、已用过 |
| `captcha_unavailable` | 503 | 验证码服务暂时不可用(服务端到 Cap 的调用失败),可稍后重试 | | `captcha_unavailable` | 503 | 验证码服务暂时不可用(服务端到 Cap 的调用失败),可稍后重试 |
| `rate_limited` | 429 | 限流 | | `rate_limited` | 429 | 限流 |
### 按账号恢复频道密钥
- `GET /api/v1/accounts/channels`:只需 `X-WD-Account`,返回当前账号已加入频道的 `{channel_id,role,payload,created_at}` 密钥包;不返回其他账号或未接受邀请的密钥包,不占席位。
- `PUT /api/v1/channels/{id}/key-package`:需要该频道 owner 能力和 owner 账号,仅更新当前 owner 自己的 `{payload}`,用于补齐已创建频道的账号密钥包。
- 创建频道可携带 `key_package`(1–16384 字符 opaque 密文),与频道和 owner 成员记录原子保存。前端必须提供;底层 API 允许省略供纯协议客户端使用。
- 发送邀请时原子保存收件账号的密钥包;接受后仍可跨设备领取。撤销邀请/成员同时删除密钥包,删除频道清理全部密钥包。
### 账号设备与资料
GET /accounts/devices 返回本账号设备 {id,name,created_at,last_seen,current}。PATCH /accounts/devices/{id} 发送 {name};DELETE 撤销该设备全部登录 sessions 与频道席位,不移除账号成员。新客户端登录时携带稳定浏览器 device_id(UUID)和设备名,各设备单独记录。个人名称/头像存储在客户端加密保险库,修改后按 modifiedAt 合并,不写入服务端明文账号表。昵称不改变固定用户名或参与密钥派生。
+25 -11
View File
@@ -2,20 +2,22 @@
## 1. 组件 ## 1. 组件
账号、设备、频道、thread 与内容加密的统一边界见 [account-channel-architecture.md](account-channel-architecture.md)。该文档明确账号成员与设备会话分离、按账号领取频道密钥包及加密文件目录。
``` ```
浏览器 (React, 独立域名 + SRI) 服务端 (.NET 10, NativeAOT) 浏览器 (React, 独立托管或 API 同源嵌入) 服务端 (.NET 10, NativeAOT)
┌──────────────────────────────┐ ┌─────────────────────────────┐ ┌──────────────────────────────┐ ┌─────────────────────────────┐
│ UI: 频道、条目、预览、二维码 │ │ Minimal API + WebSocket/SSE │ │ UI: 频道、条目、预览、二维码 │ │ Minimal API + WebSocket/SSE │
│ @webdrop/core (加解密、帧) │ 密文 + 令牌 │ 鉴权: 只比对令牌哈希 │ │ @webdrop/core (加解密、帧) │ 密文 + 令牌 │ 鉴权: 只比对令牌哈希 │
│ Worker: 流式加解密 │◄───────────►│ 日志: 分配 seq、幂等、推送 │ │ Worker: 流式加解密 │◄───────────►│ 日志: 分配 seq、幂等、推送 │
│ IndexedDB: 发送队列、本地收藏 │ │ BackgroundService: 过期清理 │ │ IndexedDB: 发送队列、本地收藏 │ │ BackgroundService: 过期清理 │
│ Service Worker: 流式下载 │ └──────┬───────────┬──────────┘ │ OPFS: 逐块写入临时文件 │ └──────┬───────────┬──────────┘
└──────────────────────────────┘ │ │ └──────────────────────────────┘ │ │
SQLite IBlobStore SQLite IBlobStore
(元数据、日志) (磁盘,预留 S3) (频道、账号密文库) (磁盘,预留 S3)
``` ```
服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。 服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。可选的 `WebDrop.Frontend` 模块只提供嵌入的静态资源,不访问频道存储或加解密代码。
## 2. 技术栈 ## 2. 技术栈
@@ -42,7 +44,7 @@
| 全球化 | `InvariantGlobalization=true` | | 全球化 | `InvariantGlobalization=true` |
| 库 | 所有项目标记 `IsAotCompatible=true`,把 IL2026、IL3050 等 AOT 和裁剪警告视为错误 | | 库 | 所有项目标记 `IsAotCompatible=true`,把 IL2026、IL3050 等 AOT 和裁剪警告视为错误 |
| HTTP/3 | `CreateSlimBuilder` 不含 HTTP/3。服务进程本身**暂不内置 QUIC**,由反向代理终结 HTTP/3,后端用 HTTP/1.1 或 HTTP/2;`WebDrop:EnableHttp3` 只是在 `/info` 里通告。需要内置时再接入 Kestrel 的 QUIC(依赖原生 msquic) | | HTTP/3 | `CreateSlimBuilder` 不含 HTTP/3。服务进程本身**暂不内置 QUIC**,由反向代理终结 HTTP/3,后端用 HTTP/1.1 或 HTTP/2;`WebDrop:EnableHttp3` 只是在 `/info` 里通告。需要内置时再接入 Kestrel 的 QUIC(依赖原生 msquic) |
| 密码学 | 服务端只做 SHA-256 和常量时间比较,`System.Security.Cryptography` 在 AOT 下可用 | | 密码学 | 内容加解密在客户端;服务端做令牌 SHA-256、登录/加入密码的加盐 PBKDF2 与常量时间比较,全部兼容 AOT |
**验证结果**(win-x64,.NET 10.0.204): **验证结果**(win-x64,.NET 10.0.204):
- Slim builder、Minimal API、原生 WebSocket、SSE、`BackgroundService`、`Microsoft.Data.Sqlite`(随包的 e_sqlite3 原生库,SQLite 3.53)在 NativeAOT 下都能正常运行。 - Slim builder、Minimal API、原生 WebSocket、SSE、`BackgroundService`、`Microsoft.Data.Sqlite`(随包的 e_sqlite3 原生库,SQLite 3.53)在 NativeAOT 下都能正常运行。
@@ -55,11 +57,17 @@
## 4. 存储模型 ## 4. 存储模型
应用契约 2 使用 `DataDir/v2/webdrop.db` 和 `DataDir/v2/blobs`,仅包含当前表结构,不维护旧 schema/旧账号保险库/旧频道认领/旧明文负载的迁移或兼容模型。开发数据可以重新创建,历史目录不自动导入。浏览器采用新的按账号/访客隔离的数据库命名空间。
SQLite 存元数据和小帧,磁盘存大帧。 SQLite 存元数据和小帧,磁盘存大帧。
| 表 | 字段 | | 表 | 字段 |
|---|---| |---|---|
| `channels` | `id` PK, `mode`, `settings`(JSON), `read_hash`, `write_hash`, `owner_hash`, `created_at`, `expires_at`, `last_seq`, `compacted_through`, `bytes` | | `channels` | `id` PK, `mode`(内容加密选项), `creator_account`, `settings`, 能力令牌哈希, 加入密码哈希/salt, 时间、序号、字节数 |
| `channel_members` | 频道 ID + username,频道内 owner/member 角色、加入时间;创建者自动为 owner |
| `channel_invitations` | 频道 ID + username、客户端加密的邀请 payload、时间 |
| `channel_join_denials` | 被移除的账号或设备标识,再次邀请账号时清除账号禁入 |
| `accounts/account_sessions` | 登录 salt、登录凭据慢哈希及独立 salt、邀请公钥、保险库密文/revision;随机 session token 的 SHA-256 与过期时间 |
| `shares` | `id` PK(与频道共用全局 id 空间), `settings`(JSON), `read_hash`, `owner_hash`(封存时清空), `creator_endpoint_hash`, `created_at`, `expires_at`, `sealed_at`, `manifest_item_id`, `bytes` | | `shares` | `id` PK(与频道共用全局 id 空间), `settings`(JSON), `read_hash`, `owner_hash`(封存时清空), `creator_endpoint_hash`, `created_at`, `expires_at`, `sealed_at`, `manifest_item_id`, `bytes` |
| `seats` | `channel_id`, `endpoint_hash`, `label` BLOB, `joined_at`;PK(`channel_id`, `endpoint_hash`) | | `seats` | `channel_id`, `endpoint_hash`, `label` BLOB, `joined_at`;PK(`channel_id`, `endpoint_hash`) |
| `share_visitors` | `share_id`, `endpoint_hash`, `joined_at`;PK(`share_id`, `endpoint_hash`),创建者终端不写入此表 | | `share_visitors` | `share_id`, `endpoint_hash`, `joined_at`;PK(`share_id`, `endpoint_hash`),创建者终端不写入此表 |
@@ -84,8 +92,9 @@ SQLite 存元数据和小帧,磁盘存大帧。
## 5. 部署 ## 5. 部署
- API 与前端使用**不同域名**:前端静态资源可以放在 CDN 或独立静态主机,API 被攻破不影响前端。 - 安全隔离部署时,API 与前端使用**不同域名**:前端静态资源可以放在独立静态主机,API 被攻破不影响前端代码。
- 前端构建产物为所有脚本和样式生成 SRI,发布时公布构建哈希。 - Compose 单机模式可以把 Vite dist 嵌入 `WebDrop.Frontend`,由 API 同源提供页面;此模式简化部署,但 API 主机也是前端代码的信任根。
- 独立托管时,前端资源应使用 HTTPS 发布;若部署 SRI,发布时公布构建哈希。
- 响应头:`Referrer-Policy: no-referrer`、严格 CSP、`X-Content-Type-Options: nosniff`。 - 响应头:`Referrer-Policy: no-referrer`、严格 CSP、`X-Content-Type-Options: nosniff`。
- 反向代理不得记录请求头(令牌)和完整 URL。 - 反向代理不得记录请求头(令牌)和完整 URL。
- 服务端日志不含频道 id 明文,只含截断后的哈希。 - 服务端日志不含频道 id 明文,只含截断后的哈希。
@@ -96,8 +105,10 @@ SQLite 存元数据和小帧,磁盘存大帧。
- 加解密在 Worker 里流式处理,文件不整份进内存。 - 加解密在 Worker 里流式处理,文件不整份进内存。
- 发送队列落在 IndexedDB,恢复网络后自动补发,依赖协议的幂等追加。 - 发送队列落在 IndexedDB,恢复网络后自动补发,依赖协议的幂等追加。
- 条目预览在沙箱 iframe 中渲染;raw 内容只当文本展示或下载。 - 条目预览在沙箱 iframe 中渲染;raw 内容只当文本展示或下载。
- 下载大文件使用 Service Worker 流式写入,兼容没有 File System Access API 的 iOS Safari。 - 下载大文件优先使用 OPFS 流式写入临时文件,认证完成后提供文件;不支持 OPFS 时回退 Blob,限制 64 MiB。
- 本地「我的频道」:链接和密钥保存在 IndexedDB,只在本机。 - 首页先注册/登录;公开频道/分享链接可按规则由访客访问。创建与管理在服务端绑定账号,频道内成员角色与设备席位分开。所有写入都检查账号或访客设备、频道角色/席位和能力权限。
- IndexedDB 按账号与访客分库;切换时中止后台订阅并清空内存。保险库只同步资源凭据、草稿清单和移除墓碑;设备 S、设备标签、缓存、原始文件和 outbox 保留在本机。新设备生成独立 S 后重新加入。
- 一个 Channel/Thread 模型共用日志、权限与生命周期;内容是否端到端加密只切换客户端封装加解密。结构 CONTROL 记录不按普通内容 TTL/max_items 清理,随频道删除。
## 7. 阶段划分 ## 7. 阶段划分
@@ -110,7 +121,8 @@ SQLite 存元数据和小帧,磁盘存大帧。
| 4 | 文件:流式加解密、GATED 拉取、下载计数、预览、粘贴和拖拽 | 100 MB 文件往返一致,下载计数正确 | | 4 | 文件:流式加解密、GATED 拉取、下载计数、预览、粘贴和拖拽 | 100 MB 文件往返一致,下载计数正确 |
| 5 | GB 级文件:并行上传和断点续传 | 中途断网后续传成功,内存占用平稳 | | 5 | GB 级文件:并行上传和断点续传 | 中途断网后续传成功,内存占用平稳 |
| 6 | PWA、Web Share Target、二维码和短配对码(PAKE) | 手机端走通 | | 6 | PWA、Web Share Target、二维码和短配对码(PAKE) | 手机端走通 |
| 7 | 可选账号、配额;CLI | 另行设计 | | 7 | 账号登录、客户端加密资源保险库 | 已实现客户端派生口令 verifier/保险库密钥、SQLite opaque vault、资源同步;口令丢失时明确告知无法恢复 |
| 8 | thread、虚拟文件夹与 IRC 风格聊天 | 已实现沿用频道加密日志,消息按 thread 归属并展示发送设备,文件夹元数据由客户端加密 |
每个阶段开始前,先更新 protocol.md 和测试向量,再改代码。 每个阶段开始前,先更新 protocol.md 和测试向量,再改代码。
@@ -121,3 +133,5 @@ SQLite 存元数据和小帧,磁盘存大帧。
- 服务端日志中没有 K、令牌和频道 id 明文。 - 服务端日志中没有 K、令牌和频道 id 明文。
- 抓包确认请求里没有 K 和明文。 - 抓包确认请求里没有 K 和明文。
- AOT 发布产物能在没有 .NET 运行时的机器上运行。 - AOT 发布产物能在没有 .NET 运行时的机器上运行。
界面术语采用「空间 → 频道分组 → 频道 → 文件夹/聊天」。内部 Channel/Thread 命名保留,分别映射空间/频道;分组沿用加密 CONTROL 日志,不引入新的成员或密钥体系。
+77 -26
View File
@@ -1,47 +1,98 @@
# 本地后端 Docker Compose # Podman Quadlet 部署
Compose 当前只启动 WebDrop API。前端继续使用现有的开发/独立托管方式;本地 Vite 开发服务器通过 `/api/v1` 代理访问 `127.0.0.1:5000`。API 默认只绑定 loopback,SQLite 数据保存在项目的 `./data` 目录。 WebDrop 使用 rootless Podman 运行单个 API 容器。前端先构建成静态文件,再嵌入 NativeAOT 后端;容器镜像使用 OCI 格式。仓库不再维护 Compose 配置。
## 启动 ## WSL 本机部署
在 Windows 工作区运行:
```powershell ```powershell
Copy-Item .env.example .env .\scripts\update-wsl-podman.ps1
docker compose config
docker compose up -d --build
docker compose ps
``` ```
API 地址为 `http://127.0.0.1:5000`。停止服务使用 `docker compose down`;数据库和服务器存储的帧会保留在 `./data`。删除该目录会永久删除这些数据。 本机开发直接从当前 Windows 工作区调用 WSL Podman。脚本会把源码增量同步到 WSL 的 `~/podman/webdrop/src`,在 WSL Linux 文件系统里构建 OCI 镜像,构建成功后重启 Quadlet 服务使用新镜像。构建失败时不会更新正在运行的容器;本机流程不经过镜像仓库。
## 前端连接 部署目录:
本机 `npm run dev` 使用 Vite 代理连接默认 API。独立部署前端时,在前端托管层把 `/api/v1` 代理到此 API 地址,这样无需开放 CORS。也可以为跨域托管配置 `WebDrop__CorsOrigins`,值应为明确的前端源列表。 - `~/podman/webdrop/src`:从当前 Windows 工作区同步的源码副本。
- `~/podman/webdrop/data`:SQLite 数据和服务端帧文件。首次运行时从仓库 `data/` 拷贝一次,后续更新不会覆盖它。
- `~/podman/webdrop/webdrop.container`:Quadlet 单元源文件,链接到 `~/.config/containers/systemd/webdrop.container`。
公网部署时,在 API 前配置 HTTPS 反向代理,并让代理连接 API 的 loopback 端口。不要把未加密 HTTP 直接暴露到公网。只有当该 API 端口仅能由可信代理访问时,才将 `WEBDROP_TRUST_FORWARDED_HEADERS` 设为 `true`。 服务地址为 `http://127.0.0.1:5000`。管理服务:
## 验证码 ```bash
systemctl --user status webdrop.service
默认 `CAPTCHA_PROVIDER=none`。启用 Cap 时,在 `.env` 设置: systemctl --user stop webdrop.service
systemctl --user start webdrop.service
```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`。 本机开发前端仍可运行 `cd web && npm run dev`,Vite 会代理 `/api/v1` 到本地 API。公网部署时,在 API 前配置 HTTPS 反向代理;不要把未加密 HTTP 直接暴露到公网。只有当 API 端口仅能由可信代理访问时,才信任 forwarded headers。
## 其他 Linux 主机
构建 OCI 镜像:
```bash
podman build --layers --format oci --platform linux/amd64 \
--file server/Containerfile --tag localhost/webdrop:local .
```
将 `deploy/webdrop.container` 安装到 `~/.config/containers/systemd/` 前,把数据卷路径改为该主机专用目录。然后执行:
```bash
systemctl --user daemon-reload
systemctl --user start webdrop.service
```
WSL 更新脚本在每次构建成功后直接重启 Quadlet 服务。这里不使用 registry auto-update,因为新镜像已经在本机刚刚构建完成。
## NAS 开发与生产
NAS 开发环境使用 `deploy/webdrop-dev.container`,镜像固定跟踪 `registry.oeo.one/justme/webdrop-api:dev`;开发 CI 成功后会推送该标签。此单元设置 `AutoUpdate=registry`,并将用户级 timer 调整为启动后一分钟检查、之后每五分钟检查一次,发现新镜像就重启 Quadlet 服务;也可以手动执行 `podman auto-update` 立即检查。它使用独立的 `webdrop-dev` 容器、5001 本机端口和 `~/podman/webdrop-dev/data`,与 WSL 本机开发及生产数据隔离。
在 NAS 安装该单元前,先以 `justme` 登录 registry 并把凭据保存在用户级 auth file:
```bash
mkdir -p ~/.config/containers
printf '%s' "$ZOT_SECRET" | podman login --authfile ~/.config/containers/auth.json \
--username justme --password-stdin registry.oeo.one
chmod 600 ~/.config/containers/auth.json
mkdir -p ~/.config/containers/systemd ~/podman/webdrop-dev/data
cp deploy/webdrop-dev.container ~/.config/containers/systemd/
mkdir -p ~/.config/systemd/user/podman-auto-update.timer.d
cp deploy/podman-auto-update.timer.d/override.conf \
~/.config/systemd/user/podman-auto-update.timer.d/override.conf
systemctl --user daemon-reload
systemctl --user start webdrop-dev.service
systemctl --user enable --now podman-auto-update.timer
systemctl --user restart podman-auto-update.timer
```
为确保 NAS 注销 SSH 会话后 user service 仍持续运行,管理员需要为该账号启用 lingering:`sudo loginctl enable-linger justme`。
不要把 `ZOT_SECRET` 写入仓库或 Quadlet 文件。生产使用 release workflow 发布的 `vMAJOR.MINOR.PATCH` 标签,应固定到明确版本,不要跟踪 `dev`。
验证码配置写在 Quadlet 的 `[Container]` 段,例如:
```ini
Environment=WebDrop__Captcha__Provider=cap
Environment=WebDrop__Captcha__ApiEndpoint=https://captcha.example.com/<site-key>/
Environment=WebDrop__Captcha__SiteverifyUrl=https://captcha.example.com/<site-key>/siteverify
Environment=WebDrop__Captcha__Secret=<server-side-secret>
```
`SiteverifyUrl` 和 `Secret` 仅供 API 容器使用。修改后运行 `systemctl --user daemon-reload` 并重启服务。
## Woodpecker CI ## Woodpecker CI
`.woodpecker/ci.yml` 在 push 和手动运行时执行 API 契约、Core、Web、服务端检查;每次 push 都会构建前端。`.woodpecker/release-backend.yml` 响应 `vMAJOR.MINOR.PATCH` SemVer tag;也可手动运行并传入 `RELEASE_TAG`(例如 `v0.1.0`)。测试通过后用 Linux native agent 的 Buildah 构建并推送同名后端镜像 tag。两者都通过 Woodpecker secret `gitea_clone_webdrop` 从 `https://git.oeo.one` 克隆公开仓库。 `.woodpecker/ci.yml` 在默认分支 push、pull request 和手动运行时执行 API 契约与服务端检查;`.woodpecker/web-build.yml` 同时执行 Core 测试向量、类型检查、Web CSS lint、测试和构建。两个 workflow 独立运行,让前后端验证并行。默认分支当前为 `master`,配置也兼容迁移后的 `main`。
两条流水线固定到 `ci: domain-linux-native` 的 `linux/amd64` Agent;该主机需要 Node 24、.NET 10 和 Buildah。release job 在 `WOODPECKER_BACKEND_LOCAL_TEMP_DIR` 对应的持久目录缓存 Buildah 层,Dockerfile 单独缓存 NuGet restore 层。Gitea webhook 走同 Docker 网络中的 `woodpecker-server:8000`,代码 clone 走公网 Git 域名。 默认分支每次 push 和 PR 都会跑常规检查。提交信息含 `[SKIP TESTS]` 时只跳过服务端测试;API 契约检查和前端/Core 检查照常运行。提交信息含 `[CI]` 时,`.woodpecker/dev-image.yml` 会等 `ci` 和 `web-build` 都通过后,构建并推送带提交 SHA 的开发镜像以及可变 `dev` 标签。开发镜像不会覆盖 release tag 或 `latest`。发布目标为 `registry.oeo.one/justme/webdrop-api`;Buildah 使用 Woodpecker secret `zot_secret` 作为 Zot API key 的密码字段,用户名为 `justme`,并启用 TLS 校验。Gitea clone 凭据保存在 `gitea_clone_webdrop` secret。
后端镜像发布到 `zot.oeo.one/justme/webdrop-api`。Woodpecker Linux agent 通过内网 `192.168.2.162:5000` 访问同一 Zot 仓库,发布需要 Buildah 与 tag/manual 事件可用的 secrets `registry_username`、`registry_password`。使用已发布镜像时,在 `.env` 中设置 `WEBDROP_IMAGE_TAG`,仓库名已填入 `.env.example`。 `.woodpecker/release-backend.yml` 响应 `vMAJOR.MINOR.PATCH` SemVer tag;也可手动运行并传入 `RELEASE_TAG`。发布 workflow 会重新执行 Core、Web 和服务端完整检查,全部通过后才用 Linux native agent 的 Buildah 构建 OCI 镜像并推送后端镜像 tag 与 `latest`。所有 workflow 都通过 Woodpecker secret `gitea_clone_webdrop` 从 `https://git.oeo.one` 克隆公开仓库。
API 契约版本以 `contracts/api-contract.json` 为准,并同步到 OpenAPI 与后端 `/api/v1/info`。加字段等向后兼容更新递增 minor,兼容性修复递增 patch,破坏性更新递增 major。前端声明支持的契约主版本;后端主版本变化时,前端 CI 契约检查和运行时连接检查都会失败,直到前端同步支持该主版本。 两条流水线固定到 `ci: domain-linux-native` 的 `linux/amd64` Agent;该主机需要 Node 24、.NET 10 和 Buildah。release job 在 `WOODPECKER_BACKEND_LOCAL_TEMP_DIR` 对应的持久目录缓存 Buildah 层;`server/Containerfile` 使用带 NativeAOT 前置工具的 `10.0-noble-aot` SDK 镜像,不再运行 apt 安装 clang/zlib;NuGet restore 层也由 Buildah 缓存。Gitea webhook 使用 Woodpecker server 所在网络,代码 clone 走公网 Git 域名。
## 数据备份 ## 数据备份
停止服务或使用一致性快照后备份 `./data`。目录里保存 SQLite 元数据和服务端收到的帧字节;加密频道/分享的内容帧是密文。备份仍需按敏感数据保护,因为 raw 频道按设计保存明文负载。 停止服务或使用一致性快照后备份 `~/podman/webdrop/data`。目录里保存 SQLite 元数据和服务端收到的帧字节;加密频道/分享的内容帧是密文。备份仍需按敏感数据保护,因为 raw 频道按设计保存明文负载。
+38 -14
View File
@@ -10,7 +10,7 @@
| 语言 | TypeScript,`strict` + `noUncheckedIndexedAccess` | | | 语言 | TypeScript,`strict` + `noUncheckedIndexedAccess` | |
| UI | React 19.3(稳定版,含 `ViewTransition`、`Activity`) | 不使用 canary | | UI | React 19.3(稳定版,含 `ViewTransition`、`Activity`) | 不使用 canary |
| 状态 | Zustand | 按领域拆 store | | 状态 | Zustand | 按领域拆 store |
| 本地存储 | `idb`(IndexedDB) | 发送队列、「我的频道」、草稿预览所需的本机文件 | | 本地存储 | `idb`(IndexedDB) | 发送队列、「我的频道」、账号资源工作副本、草稿预览所需的本机文件 |
| 样式 | 原生 CSS + CSS 变量 + CSS Modules | 无 Tailwind、无 CSS-in-JS | | 样式 | 原生 CSS + CSS 变量 + CSS Modules | 无 Tailwind、无 CSS-in-JS |
| 路由 | 自写极简导航(`app/navigation.ts`) | 只有两三个路由,且需统一挂 view transition | | 路由 | 自写极简导航(`app/navigation.ts`) | 只有两三个路由,且需统一挂 view transition |
| 测试 | Vitest(单元)、Playwright(端到端) | | | 测试 | Vitest(单元)、Playwright(端到端) | |
@@ -20,6 +20,8 @@
**依赖策略**:新增运行时依赖需要书面理由(解决什么、为何不能自写、体积)。不引入 UI 组件库、动画库、图标库、日期库。依赖越少,越容易审计;前端是整个方案的信任根。 **依赖策略**:新增运行时依赖需要书面理由(解决什么、为何不能自写、体积)。不引入 UI 组件库、动画库、图标库、日期库。依赖越少,越容易审计;前端是整个方案的信任根。
生产构建将 `web/package.json` 的版本、前后端各自的 commit/tag 和 UTC 构建时间编译进前端,首页右下角将时间换算为 Asia/Shanghai 并格式化为 `yyyy-MM-dd HH:mm`。部署构建通过 `WEBDROP_FRONTEND_COMMIT`、`WEBDROP_FRONTEND_TAG`、`WEBDROP_BACKEND_COMMIT`、`WEBDROP_BACKEND_TAG`、`WEBDROP_BUILD_TIME` 提供元数据;本地构建没有 CI 环境时尝试读取 Git,时间取本次构建时间。
## 2. 目录与分层 ## 2. 目录与分层
``` ```
@@ -91,6 +93,8 @@ type ItemState =
| 降级防御 | 链接含 `k` 就只接受 enc 且认证通过的帧 | | 降级防御 | 链接含 `k` 就只接受 enc 且认证通过的帧 |
| 不把密钥、令牌、明文写入 `console`、错误上报、URL、`localStorage` | 「我的频道」只存 IndexedDB | | 不把密钥、令牌、明文写入 `console`、错误上报、URL、`localStorage` | 「我的频道」只存 IndexedDB |
| 便捷分享草稿预览 | 原始文件只缓存在创建者本机 IndexedDB,不会随文件帧上传;移除条目或封存时清理,用于刷新后继续预览草稿 | | 便捷分享草稿预览 | 原始文件只缓存在创建者本机 IndexedDB,不会随文件帧上传;移除条目或封存时清理,用于刷新后继续预览草稿 |
| 账号资源保险库 | Worker 派生登录凭据与 AES-GCM 密钥;仅同步 link/ownerLink、草稿清单、邀请私钥和移除墓碑;不含终端 S、设备名、缓存或文件 Blob。按账号/访客分库,会话在 Worker 中使用,通过本机 IndexedDB 恢复 |
| thread 与虚拟文件夹 | `@webdrop/core` 用共享结构编码 `thread_id`/`folder_id` 和 CONTROL;mode 只切换是否加密;worker 解析控制正文,store 保留最新位置以防旧条目更新回退 |
| 条目预览在沙箱 iframe 渲染;raw 内容只当纯文本显示或下载 | 无内联渲染,不使用 `dangerouslySetInnerHTML` | | 条目预览在沙箱 iframe 渲染;raw 内容只当纯文本显示或下载 | 无内联渲染,不使用 `dangerouslySetInnerHTML` |
| 严格 CSP,所有脚本样式带 SRI,不使用内联脚本 | 与 architecture §5 一致 | | 严格 CSP,所有脚本样式带 SRI,不使用内联脚本 | 与 architecture §5 一致 |
| 不引入任何第三方脚本、统计、字体 CDN | 字体自托管 | | 不引入任何第三方脚本、统计、字体 CDN | 字体自托管 |
@@ -157,18 +161,38 @@ type ItemState =
在 `@webdrop/core` 未就绪前,`core-bridge/` 按 protocol.md 定义的接口先放适配层和假实现,真实实现就绪后替换,组件与 store 不改。 在 `@webdrop/core` 未就绪前,`core-bridge/` 按 protocol.md 定义的接口先放适配层和假实现,真实实现就绪后替换,组件与 store 不改。
## 12. 与 `@webdrop/core` 对接备忘 ## 12. 实际边界与会话
`core-bridge/api.ts` 是前端与后端的唯一边界,现在由 `mock.ts` 实现。接 `@webdrop/core`([packages/core/README.md](../packages/core/README.md))时对应关系: core-bridge/instance.ts 使用真实 Worker 实现,mock 仅用于原型和测试。账号与设备由 worker/account.ts 编排;账号密钥包通过 accountChannels.ts 与 channelGrant.ts 领取和核对;频道成员/设置/设备席位通过 channelResources.ts 调用 API;thread/folder/file.published 共用 channelThreads.ts 的加密 CONTROL 日志。
| 前端需要 | core / 协议 | @webdrop/core 仅实现纯密码学与帧协议,不访问数据库或网络。sessions.ts 管本机已解锁会话。transfer.ts 管持久化密文发送队列,队列按资源/创建顺序索引,确保文件密文帧先于目录记录上传。downloadStorage.ts 优先使用 OPFS 逐块写入临时文件,认证结束后交付;不支持时最多 64 MiB 的 Blob 回退。
|---|---|
| 新建频道或分享 | `ChannelSession.create({ mode, settings, endpointSecret })` → `POST /channels`;分享的 `settings` 为 `members_can_upload=false`、`delete_policy=owner_only`,并设 `seats`、`ttl_seconds`、`file_max_downloads` | 频道和 Share 各有独立领域 API 与生命周期;Share 封存由服务端事务清除 owner_hash,不能再写。账号保险库同步个人资料、邀请私钥、资源凭据和移除墓碑;设备 S、文件缓存与 outbox 保留本机。
| 草稿阶段调整设置 | `PATCH /channels/{id}/settings`(需所有者令牌) |
| **完成分享** | 客户端丢弃所有者密钥 A:用成员链接重新 `ChannelSession.open(...)`,此后 `session.can('owner')` 为 false。丢弃是客户端的自觉行为,服务端不知道;若要强制,需后端提供「封存」让服务端作废 `owner_hash`(属于后端与协议改动,前端只提请求) | ## 13. 账号会话恢复与频道密钥领取
| 文件下载次数 | 每个条目的 `maxDownloads` 会强制 GATED,并带 `X-WD-Max-Downloads`,**必须随第 0 帧发送**。文本不要设,否则读文本也要拉取 |
| 内联渲染不烧次数 | GATED 条目每次 `GET /items/{id}/frames?from=0` 计一次下载;自动加载只用于非 GATED(见 ui-design §6.5) | 本机 IndexedDB 保存账号 session token、不可导出的保险库 CryptoKey 和最近验证的加密保险库,不保存密码或登录 verifier。启动时先恢复账号并验证服务端 session,再读取该账号的资源数据库;网络离线保留本机身份,服务端明确拒绝 session 时清除登录凭据。退出登录清除本机自动恢复记录。记住登录允许使用本浏览器的人访问账号资源。
| 终端密钥 | `session.endpointSecret` 必须持久化到 IndexedDB,否则每次打开都占一个新席位。原型暂未做 |
| 创建频道、便捷分享的验证码 | 读 `GET /info` 的 `captcha`;需要时按需加载打包在前端的官方 `cap-widget`,求解后只把一次性 token 放进创建请求的 `X-WD-Cap`。不从 CDN 加载脚本 | 登录/同步后按账号领取 /accounts/channels 密钥包,解密并核对频道 ID、模式和角色,再补齐本机频道;本机主动移除的资源墓碑不会被旧密钥包重新加入。新设备不复用设备 S。打开无 fragment 的频道地址时,已登录账号可以领取授权包,待接受邀请需用户确认加入。未授权时显示需要频道主邀请,不再只要求含密钥链接。
| 校验失败 | reader 的 `rejected` 事件不能忽略,至少在界面提示「内容校验失败」 |
| Worker | `ChannelSession` 不能跨线程,在 Worker 里用链接和 `endpointSecret` 重建 | ## 14. 用户概念映射与分组
界面 Space 对应既有顶层 ChannelInfo 与 /c/:id;界面频道对应 ThreadInfo 和 thread_id。ChannelGroupInfo 属于当前空间,ThreadInfo.groupId 可选。store 从 group-created/group-renamed/thread-grouped 事件重建分组;threadGroups 保留最新归属,避免延迟的创建事件将频道移回旧分组。分组不是文件夹,也不是成员/密钥边界。
## 15. 文件交互边界
FileExplorer 编排选择、菜单、拖放和目录导航,ExplorerFileRow 只订阅单个文件。选择范围与拖放封装在 lib/explorer.ts 中做纯逻辑校验;拖放数据仅含随机文件 ID 和当前空间/频道标识,不包含链接、密钥或文件内容。移动、删除、下载均经上层回调和 store 完成。
ContextMenu 是无业务 UI,负责 portal、视口限制、键盘焦点和关闭。悬停预览由独立组件挂载,受限文件只渲染元数据。文件详情的显式解密流程继续沿用原有认证和下载计数。
## 16. 文件聊天消息与身份快照
ItemBase.author 为可选发送者快照 {username,displayName,avatar?}。文件的快照放入已有 file.published 加密目录记录,文本通过 item.author CONTROL 记录关联。store 的 authors 索引支持身份记录先于条目到达,并在晚到的下载/进度更新中保留身份,避免取文件内容后丢失头像。该快照用于展示,不代表逐账号数字签名。
ChatPane 订阅当前频道的条目 ID,ChatMessage 单独订阅条目状态和上传进度;文件目录变成文件消息,不重复创建独立文本通知。预览/下载继续经已有 store action,移动文件不会重新上传。GATED 内容只有明确操作后才读取。
## 17. 本机凭据保存
accountPersistence 新增按 API endpoint 隔离的 credentials store。用户名和密码使用随机 IV 的 AES-GCM 密文保存;不可导出的 CryptoKey 通过 IndexedDB structured clone 保存在本机。只有登录表单读取时才在 Worker 解密并填充,不写入 localStorage、URL 或日志,不上传原始密码。加密存储不能防止已被控制的同源前端或终端。
AccountSession.remember 控制会话落盘,所有同步、资料更新与登录保存路径统一检查该标记。关闭记住或清除本机保存后,不能被轮询重新保存。正常退出保留已保存填充信息,明确清除或服务端拒绝 session 时删除。旧版保存的会话视为开启自动恢复,但没有原始密码,不伪造密码填充。
+376 -11
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: WebDrop API title: WebDrop API
version: 1.0.0 version: 2.1.0
servers: servers:
- url: /api/v1 - url: /api/v1
@@ -12,8 +12,79 @@ tags:
- name: frame - name: frame
- name: item - name: item
- name: share - name: share
- name: account
paths: paths:
/accounts/channels:
get:
tags: [account]
security: [{ accountBearer: [] }]
summary: 领取当前账号已加入频道的加密密钥包
responses:
'200':
description: 仅返回已加入频道,不占设备席位
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/AccountChannel' } }
'401': { $ref: '#/components/responses/Error' }
/accounts/devices:
get:
tags: [account]
security: [{ accountBearer: [] }]
summary: 读取当前账号的设备会话
responses:
'200':
description: 设备列表
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/AccountDevice' } }
'401': { $ref: '#/components/responses/Error' }
/accounts/devices/{id}:
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
patch:
tags: [account]
security: [{ accountBearer: [] }]
summary: 重命名本账号设备
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties: { name: { type: string, minLength: 1, maxLength: 80 } }
responses:
'204': { description: 已更新 }
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
delete:
tags: [account]
security: [{ accountBearer: [] }]
summary: 撤销该设备全部 sessions 与频道席位,保留账号成员资格
responses:
'204': { description: 已撤销 }
'401': { $ref: '#/components/responses/Error' }
/channels/{id}/key-package:
put:
tags: [channel]
security: [{ bearer: [] }]
summary: 频道主补齐给自己账号的加密密钥包
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/Role' }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [payload]
properties: { payload: { type: string, minLength: 1, maxLength: 16384 } }
responses:
'204': { description: 已保存 }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
/info: /info:
get: get:
tags: [server] tags: [server]
@@ -26,14 +97,183 @@ paths:
application/json: application/json:
schema: { $ref: '#/components/schemas/ServerInfo' } schema: { $ref: '#/components/schemas/ServerInfo' }
/accounts:
post:
tags: [account]
security: []
summary: 创建账号
description: 仅接收口令 verifier 和客户端加密保险库,不接收密码。
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/AccountRegister' }
responses:
'204': { description: 已创建 }
'400': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
'429': { $ref: '#/components/responses/Error' }
/accounts/salt:
post:
tags: [account]
security: []
summary: 查询账号口令派生 salt
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/AccountSaltRequest' }
responses:
'200':
description: 客户端 PBKDF2 salt
content:
application/json:
schema: { $ref: '#/components/schemas/AccountSalt' }
'400': { $ref: '#/components/responses/Error' }
/accounts/session:
post:
tags: [account]
security: []
summary: 验证客户端派生凭据并创建 session
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/AccountSessionRequest' }
responses:
'200':
description: 已登录
content:
application/json:
schema: { $ref: '#/components/schemas/AccountSession' }
'401': { $ref: '#/components/responses/Error' }
'400': { $ref: '#/components/responses/Error' }
delete:
tags: [account]
security: [{ accountBearer: [] }]
summary: 撤销当前账号 session
responses:
'204': { description: 已撤销 }
/accounts/vault:
get:
tags: [account]
security: [{ accountBearer: [] }]
summary: 读取客户端加密保险库
responses:
'200':
description: 当前保险库和 revision
content:
application/json:
schema: { $ref: '#/components/schemas/AccountVault' }
'401': { $ref: '#/components/responses/Error' }
put:
tags: [account]
security: [{ accountBearer: [] }]
summary: 以 revision 乐观并发更新保险库
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/AccountVault' }
responses:
'200':
description: 已更新
content:
application/json:
schema: { $ref: '#/components/schemas/AccountVault' }
'401': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/accounts/users/{username}/key:
get:
tags: [account]
security: [{ accountBearer: [] }]
summary: 查询被邀请用户公钥
parameters:
- { name: username, in: path, required: true, schema: { type: string } }
responses:
'200':
description: 公钥
content:
application/json:
schema: { $ref: '#/components/schemas/PublicKey' }
'404': { $ref: '#/components/responses/Error' }
/accounts/invitations:
get:
tags: [account]
security: [{ accountBearer: [] }]
summary: 当前账号收到的邀请
responses:
'200':
description: 邀请列表
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/ChannelInvitation' } }
/channels/{id}/members:
get:
tags: [channel]
security: [{ bearer: [] }]
summary: 读取频道内账号成员及角色
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/Role' }
- { $ref: '#/components/parameters/Endpoint' }
responses:
'200':
description: 成员列表
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/ChannelMember' } }
/channels/{id}/invitations:
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/RoleOwner' }
get:
tags: [channel]
security: [{ bearer: [], accountBearer: [] }]
summary: 频道主查询邀请
responses:
'200':
description: 邀请列表
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/ChannelInvitation' } }
post:
tags: [channel]
security: [{ bearer: [], accountBearer: [] }]
summary: 邀请已注册用户
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/InvitationRequest' }
responses:
'204': { description: 已邀请 }
'404': { $ref: '#/components/responses/Error' }
/channels/{id}/invitations/{username}:
delete:
tags: [channel]
security: [{ bearer: [], accountBearer: [] }]
summary: 撤销邀请和成员授权并移除其设备
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/RoleOwner' }
- { name: username, in: path, required: true, schema: { type: string } }
responses:
'204': { description: 已移除 }
'403': { $ref: '#/components/responses/Error' }
/channels: /channels:
post: post:
tags: [channel] tags: [channel]
security: [] security: [{ accountBearer: [] }]
summary: 创建频道 summary: 创建频道
description: | description: |
服务端启用验证码时需要 `X-WD-Cap`(Cap 的 token,见 protocol §11),并需要终端头 `X-WD-Endpoint`。 服务端启用验证码时需要 `X-WD-Cap`(Cap 的 token,见 protocol §11),并需要终端头 `X-WD-Endpoint`。
创建者的终端自动入席,占一个席位。 创建者的账号自动成为该频道 owner,其终端自动入席。
parameters: parameters:
- { $ref: '#/components/parameters/Captcha' } - { $ref: '#/components/parameters/Captcha' }
- { $ref: '#/components/parameters/Endpoint' } - { $ref: '#/components/parameters/Endpoint' }
@@ -170,6 +410,30 @@ paths:
'403': { $ref: '#/components/responses/Error' } '403': { $ref: '#/components/responses/Error' }
'413': { $ref: '#/components/responses/Error' } '413': { $ref: '#/components/responses/Error' }
/channels/{id}/seats/{endpointHash}/label:
put:
tags: [seat]
summary: 所有者重命名设备
description: 请求体是设备名称(≤256 B);enc 模式以目标 endpoint hash 作为 AAD 加密,raw 模式为明文。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- name: endpointHash
in: path
required: true
description: 终端标识,即 `base64url(SHA-256(S))`
schema: { $ref: '#/components/schemas/Base64Url' }
- { $ref: '#/components/parameters/RoleOwner' }
requestBody:
required: true
content:
application/octet-stream:
schema: { type: string, format: binary, maxLength: 256 }
responses:
'204': { description: 名称已更新 }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'413': { $ref: '#/components/responses/Error' }
/channels/{id}/seats/{endpointHash}: /channels/{id}/seats/{endpointHash}:
delete: delete:
tags: [seat] tags: [seat]
@@ -196,7 +460,7 @@ paths:
description: | description: |
请求体是完整的帧封装(26 字节头 + 负载),格式见 `protocol.md` §4。 请求体是完整的帧封装(26 字节头 + 负载),格式见 `protocol.md` §4。
幂等键为 `(channel_id, item_id, frame_index)`。内容相同则 200 返回原 seq,内容不同则 409。 幂等键为 `(channel_id, item_id, frame_index)`。内容相同则 200 返回原 seq,内容不同则 409。
第 0 帧可带 `X-WD-Max-Downloads` 和(raw 模式)`X-WD-Label`。 第 0 帧可带 `X-WD-Max-Downloads`;名称与线程字段统一在条目元数据中。
security: [{ bearer: [] }] security: [{ bearer: [] }]
parameters: parameters:
- { $ref: '#/components/parameters/ChannelId' } - { $ref: '#/components/parameters/ChannelId' }
@@ -207,11 +471,6 @@ paths:
required: false required: false
schema: { type: integer, minimum: 0 } schema: { type: integer, minimum: 0 }
description: 条目的下载上限,0 为不限。只对 GATED 条目生效。 description: 条目的下载上限,0 为不限。只对 GATED 条目生效。
- name: X-WD-Label
in: header
required: false
schema: { type: string, maxLength: 255 }
description: raw 模式的条目标签,明文。
requestBody: requestBody:
required: true required: true
content: content:
@@ -389,7 +648,7 @@ paths:
/shares: /shares:
post: post:
tags: [share] tags: [share]
security: [] security: [{ accountBearer: [] }]
summary: 创建便捷分享草稿 summary: 创建便捷分享草稿
description: 启用验证码时需携带 Cap token。创建者 endpoint 可选,创建者不计入访客数。 description: 启用验证码时需携带 Cap token。创建者 endpoint 可选,创建者不计入访客数。
parameters: parameters:
@@ -583,6 +842,11 @@ paths:
components: components:
securitySchemes: securitySchemes:
accountBearer:
type: apiKey
in: header
name: X-WD-Account
description: WebDrop 账号登录后颁发的随机 session token。
bearer: bearer:
type: http type: http
scheme: bearer scheme: bearer
@@ -652,6 +916,88 @@ components:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
schemas: schemas:
AccountChannel:
type: object
required: [channel_id, role, payload, created_at]
properties:
channel_id: { $ref: '#/components/schemas/Base64Url' }
role: { type: string, enum: [owner, member] }
payload: { type: string, minLength: 1, maxLength: 16384 }
created_at: { type: integer, format: int64 }
AccountDevice:
type: object
required: [id, name, created_at, last_seen, current]
properties:
id: { type: string, format: uuid }
name: { type: string, minLength: 1, maxLength: 80 }
created_at: { type: integer, format: int64 }
last_seen: { type: integer, format: int64 }
current: { type: boolean }
PublicKey:
type: object
required: [public_key]
properties:
public_key: { type: string, maxLength: 1024 }
ChannelInvitation:
type: object
required: [channel_id, username, payload, created_at]
properties:
channel_id: { $ref: '#/components/schemas/Base64Url' }
username: { type: string }
payload: { type: string, maxLength: 16384, description: 客户端加密的成员链接 }
created_at: { type: integer, format: int64 }
InvitationRequest:
type: object
required: [username, payload]
properties:
username: { type: string }
payload: { type: string, maxLength: 16384 }
ChannelMember:
type: object
required: [username, role, joined_at]
properties:
username: { type: string }
role: { type: string, enum: [owner, member] }
joined_at: { type: integer, format: int64 }
AccountRegister:
type: object
required: [username, salt, verifier, vault, public_key]
properties:
username: { type: string, minLength: 3, maxLength: 32, pattern: '^[A-Za-z0-9_-]+$' }
salt: { type: string, contentEncoding: base64, minLength: 24, maxLength: 24 }
verifier: { type: string, contentEncoding: base64, minLength: 44, maxLength: 44 }
vault: { type: string, contentEncoding: base64, maxLength: 1733336 }
public_key: { type: string, maxLength: 1024, description: P-256 ECDH 公钥 JWK 的 JSON 字符串 }
AccountSaltRequest:
type: object
required: [username]
properties:
username: { type: string, minLength: 3, maxLength: 32 }
AccountSalt:
type: object
required: [salt]
properties:
salt: { type: string, contentEncoding: base64 }
AccountSessionRequest:
type: object
required: [username, verifier]
properties:
device_id: { type: string, format: uuid }
device_name: { type: string, minLength: 1, maxLength: 80 }
username: { type: string }
verifier: { type: string, contentEncoding: base64 }
AccountSession:
type: object
required: [username, token]
properties:
username: { type: string }
token: { type: string, contentEncoding: base64 }
AccountVault:
type: object
required: [revision, vault]
properties:
revision: { type: integer, format: int64, minimum: 0 }
vault: { type: string, contentEncoding: base64, maxLength: 1733336 }
Base64Url: Base64Url:
type: string type: string
pattern: '^[A-Za-z0-9_-]+$' pattern: '^[A-Za-z0-9_-]+$'
@@ -675,6 +1021,13 @@ components:
- rate_limited - rate_limited
- visitors_full - visitors_full
- sealed - sealed
- account_required
- account_invalid
- invitation_required
- channel_password_required
- channel_password_invalid
- username_unavailable
- vault_conflict
Error: Error:
type: object type: object
@@ -694,7 +1047,7 @@ components:
required: [protocol, contract_version, limits, captcha, transports] required: [protocol, contract_version, limits, captcha, transports]
properties: properties:
protocol: { type: integer, const: 1 } protocol: { type: integer, const: 1 }
contract_version: { type: string, example: 1.0.0, description: API 契约的 SemVer。破坏性变更递增主版本。 } contract_version: { type: string, example: 2.0.0, description: API 契约的 SemVer。破坏性变更递增主版本。 }
captcha: captcha:
type: object type: object
required: [provider, enabled] required: [provider, enabled]
@@ -731,6 +1084,9 @@ components:
type: object type: object
required: [seats, members_can_upload, delete_policy, ttl_seconds, max_items, file_max_downloads, ui_restrict_copy_download] required: [seats, members_can_upload, delete_policy, ttl_seconds, max_items, file_max_downloads, ui_restrict_copy_download]
properties: properties:
join_policy: { type: string, enum: [open, invite], default: open }
allow_guests: { type: boolean, default: true }
name: { type: string, maxLength: 48, description: "共享频道名,所有成员和服务端可见" }
seats: { type: integer, minimum: 1, default: 2 } seats: { type: integer, minimum: 1, default: 2 }
members_can_upload: { type: boolean, default: true } members_can_upload: { type: boolean, default: true }
delete_policy: { $ref: '#/components/schemas/DeletePolicy' } delete_policy: { $ref: '#/components/schemas/DeletePolicy' }
@@ -743,6 +1099,10 @@ components:
type: object type: object
description: ChannelSettings 的全部字段均可选 description: ChannelSettings 的全部字段均可选
properties: properties:
join_policy: { type: string, enum: [open, invite] }
allow_guests: { type: boolean }
join_password: { type: string, maxLength: 256, description: 至少 8 字符;空字符串清除;省略保持原值 }
name: { type: string, maxLength: 48, description: "更新共享频道名;空字符串清除名称" }
seats: { type: integer, minimum: 1 } seats: { type: integer, minimum: 1 }
members_can_upload: { type: boolean } members_can_upload: { type: boolean }
delete_policy: { $ref: '#/components/schemas/DeletePolicy' } delete_policy: { $ref: '#/components/schemas/DeletePolicy' }
@@ -755,6 +1115,8 @@ components:
type: object type: object
required: [id, mode, settings, owner_hash] required: [id, mode, settings, owner_hash]
properties: properties:
key_package: { type: string, minLength: 1, maxLength: 16384, description: 用创建者账号公钥封装的 owner 凭据 }
join_password: { type: string, minLength: 8, maxLength: 256 }
id: { $ref: '#/components/schemas/Base64Url' } id: { $ref: '#/components/schemas/Base64Url' }
mode: { $ref: '#/components/schemas/ChannelMode' } mode: { $ref: '#/components/schemas/ChannelMode' }
settings: { $ref: '#/components/schemas/ChannelSettings' } settings: { $ref: '#/components/schemas/ChannelSettings' }
@@ -803,6 +1165,7 @@ components:
type: object type: object
required: [item_id, bytes, frames, max_downloads, downloads_left] required: [item_id, bytes, frames, max_downloads, downloads_left]
properties: properties:
complete: { type: boolean }
item_id: { $ref: '#/components/schemas/Base64Url' } item_id: { $ref: '#/components/schemas/Base64Url' }
bytes: { type: integer, format: int64 } bytes: { type: integer, format: int64 }
frames: { type: integer } frames: { type: integer }
@@ -828,6 +1191,8 @@ components:
type: object type: object
required: [id, mode, settings, seats_used, oldest_seq, last_seq, created_at, expires_at] required: [id, mode, settings, seats_used, oldest_seq, last_seq, created_at, expires_at]
properties: properties:
password_required: { type: boolean }
creator_account: { type: string }
id: { $ref: '#/components/schemas/Base64Url' } id: { $ref: '#/components/schemas/Base64Url' }
mode: { $ref: '#/components/schemas/ChannelMode' } mode: { $ref: '#/components/schemas/ChannelMode' }
settings: { $ref: '#/components/schemas/ChannelSettings' } settings: { $ref: '#/components/schemas/ChannelSettings' }
+41 -7
View File
@@ -12,18 +12,18 @@
|---|---|---| |---|---|---|
| `channel_id` | 16 B | 随机,客户端生成 | | `channel_id` | 16 B | 随机,客户端生成 |
| `item_id` | 16 B | 随机,客户端生成 | | `item_id` | 16 B | 随机,客户端生成 |
| `K` | 32 B | 频道密钥,仅 enc 模式,只存在于 URL fragment | | `K` | 32 B | 频道密钥,仅 enc 模式,按账号加密封装;可选能力链接使用 fragment |
| `A` | 32 B | 管理密钥,独立随机,只存在于所有者链接 | | `A` | 32 B | 管理密钥,独立随机,只存在于所有者链接 |
| `W` | 32 B | 写密钥,仅 raw 模式且成员可上传时 | | `W` | 32 B | 写密钥,仅 raw 模式且成员可上传时 |
| `S` | 32 B | 终端密钥,每个频道每个终端各生成一个,仅存本地 | | `S` | 32 B | 终端密钥,每个频道每个终端各生成一个,仅存本地 |
## 2. 模式与链接 ## 2. 模式与链接
频道创建时二选一,之后不可更改。 只有一个频道模型。创建时可选择是否启用端到端加密,默认 enc;raw 表示不加密内容。账号成员、所有者、加入规则、线程、目录、日志和权限完全共用,差别限于客户端的负载加解密。创建后不能改变加密方式。
| | enc(默认) | raw | | | enc(默认) | raw |
|---|---|---| |---|---|---|
| 负载 | AES-256-GCM 密文 | 原样字节,服务端可见 | | 负载 | AES-256-GCM 密文 | 共享条目结构的明文,服务端可见 |
| 所有者链接 | `/c/{id}#k=<K>&a=<A>` | `/c/{id}#a=<A>[&w=<W>]` | | 所有者链接 | `/c/{id}#k=<K>&a=<A>` | `/c/{id}#a=<A>[&w=<W>]` |
| 成员链接 | `/c/{id}#k=<K>` | `/c/{id}[#w=<W>]` | | 成员链接 | `/c/{id}#k=<K>` | `/c/{id}[#w=<W>]` |
| 读权限 | 持 `T_read` | 知道 id 即可(能力链接) | | 读权限 | 持 `T_read` | 知道 id 即可(能力链接) |
@@ -70,7 +70,8 @@ raw 模式下 `T_owner` 同样由 `A` 派生;`T_write = W`。
| 1 | SNAPSHOT | 该条目是频道的「最新快照」,取代之前的快照条目 | | 1 | SNAPSHOT | 该条目是频道的「最新快照」,取代之前的快照条目 |
| 2 | GATED | 不通过实时推送内联分发,只能按条目拉取,并计下载次数 | | 2 | GATED | 不通过实时推送内联分发,只能按条目拉取,并计下载次数 |
| 3 | ENC | 负载为 AES-GCM 密文,必须与频道模式一致 | | 3 | ENC | 负载为 AES-GCM 密文,必须与频道模式一致 |
| 4-7 | 保留 | 必须为 0 | | 4 | CONTROL | 频道结构控制条目 |
| 5-7 | 保留 | 必须为 0 |
服务端拒绝不符合上述规则的封装,但从不解析负载。 服务端拒绝不符合上述规则的封装,但从不解析负载。
@@ -120,15 +121,21 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
| `size` | 可选,内容总字节数(流式时可省略) | | `size` | 可选,内容总字节数(流式时可省略) |
| `ts` | 毫秒时间戳,仅供参考,排序以 seq 为准 | | `ts` | 毫秒时间戳,仅供参考,排序以 seq 为准 |
| `dev` | 可选,设备名 | | `dev` | 可选,设备名 |
| `thread_id` | 可选,频道 thread ID |
| `folder_id` | 可选,文件的虚拟文件夹 ID |
第 1 帧起,`data` 全部是内容字节。小内容(文本、JSON)一帧完成,没有额外开销。 第 1 帧起,`data` 全部是内容字节。小内容(文本、JSON)一帧完成,没有额外开销。
**raw 模式**:负载就是内容字节,没有 meta。条目名称等信息由用户在上传时放进 `X-WD-Label`(明文,≤255 B)。 **频道 thread 与文件夹**:客户端把 thread/folder 元数据放在上述共享元数据中(enc 时加密,raw 时服务端可见)。thread 的创建、重命名,文件夹的创建/重命名,以及条目移动等操作以 `kind=other` 的 CONTROL 条目追加到同一频道日志;控制正文是版本化 JSON,仅客户端识别。它们不增加服务端表或第二套同步协议。新内容必须归属已创建的 thread;前端新建频道后自动发布“常规”thread 创建记录。GATED 的 announce 不含元数据,但 file.published 加密目录记录可在不下载内容时提供名称和归属;没有目录记录时显示通用占位符。
**raw 模式(API 契约 2)**:负载使用与 enc 相同的 `u16 meta_len ‖ meta_json ‖ content` 条目结构,只是不加密。名称、thread/folder ID 与其他元数据对服务端可见。旧版“纯字节、无元数据”的 raw 负载不属于新应用契约,旧客户端会被 API 主版本检查拒绝。
**未完成的条目**:没有 FINAL 的条目是「进行中」,接收方可以渐进消费(流式播放、边收边写),但必须标记为不完整,直到收到已认证的 FINAL。 **未完成的条目**:没有 FINAL 的条目是「进行中」,接收方可以渐进消费(流式播放、边收边写),但必须标记为不完整,直到收到已认证的 FINAL。
## 8. 日志、序号与条目生命周期 ## 8. 日志、序号与条目生命周期
`CONTROL=0x10` 标记频道结构控制条目;保留位为 `0xE0`。控制条目必须是非 GATED、非 SNAPSHOT 的 FINAL 单帧,payload 最多 16,400 B;其生命周期为频道生命周期,不受普通内容 TTL 或 max_items 淘汰。每频道最多 4096 条控制记录,仍计入总字节配额。加密和非加密频道使用相同控制正文与日志处理。
频道维护一条服务端排序的**日志**,每条记录的序号 `seq` 是 u64,频道内单调递增。 频道维护一条服务端排序的**日志**,每条记录的序号 `seq` 是 u64,频道内单调递增。
| kind | 值 | 内容 | | kind | 值 | 内容 |
@@ -164,11 +171,12 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
- 终端通过显式的加入请求(`POST .../seats`)占用一个席位;创建者的终端在创建时自动入席。席位已满则拒绝新终端(`seats_full`),已入席的终端可重复加入(幂等)和重连。未入席的终端访问频道返回 `not_joined`。 - 终端通过显式的加入请求(`POST .../seats`)占用一个席位;创建者的终端在创建时自动入席。席位已满则拒绝新终端(`seats_full`),已入席的终端可重复加入(幂等)和重连。未入席的终端访问频道返回 `not_joined`。
- 不同频道使用不同的 `S`,避免服务端把同一设备关联起来。 - 不同频道使用不同的 `S`,避免服务端把同一设备关联起来。
- 终端可以加密一个设备名上传,所有者能看到成员列表和设备名,并可以释放席位。 - 终端可以加密一个设备名上传,所有者能看到成员列表和设备名,并可以释放席位。
- 终端可更新自己的名称;所有者也可重命名任意席位。enc 模式重命名仍使用同一份加密设备标签,并将 AAD 绑定目标 endpoint hash;raw 模式标签仍为明文。所有者移除席位后,该终端不能继续读取频道,但持有成员链接的人仍可重新加入(若有空席位)。
- **设备名格式(enc)**:`iv(12 B 随机) ‖ AES-256-GCM(K_label, utf8 名称) ‖ tag(16 B)`,AAD = `"WD1L"`(4 B ASCII)‖ `channel_id` ‖ `endpoint_hash`(32 B,即 `SHA-256(S)`)。总长不超过 256 B,所以名称最多 228 字节 UTF-8。使用派生子密钥 `K_label` 而不是直接用 `K_enc`,避免同一密钥同时用于 HKDF 和 AES-GCM;AAD 绑定终端,服务端无法把一个终端的设备名挪到另一个席位上。raw 模式下设备名是明文 UTF-8。 - **设备名格式(enc)**:`iv(12 B 随机) ‖ AES-256-GCM(K_label, utf8 名称) ‖ tag(16 B)`,AAD = `"WD1L"`(4 B ASCII)‖ `channel_id` ‖ `endpoint_hash`(32 B,即 `SHA-256(S)`)。总长不超过 256 B,所以名称最多 228 字节 UTF-8。使用派生子密钥 `K_label` 而不是直接用 `K_enc`,避免同一密钥同时用于 HKDF 和 AES-GCM;AAD 绑定终端,服务端无法把一个终端的设备名挪到另一个席位上。raw 模式下设备名是明文 UTF-8。
## 11. 创建与验证码 ## 11. 创建与验证码
创建频道是匿名接口,需要防刷。验证码**完全交给 [Cap](https://github.com/tiagozip/cap)**(基于 SHA-256 工作量证明的验证码),本协议不定义、也不实现任何 PoW。 创建频道需要账号登录,仍需要防刷。验证码**完全交给 [Cap](https://github.com/tiagozip/cap)**(基于 SHA-256 工作量证明的验证码),本协议不定义、也不实现任何 PoW。
流程: 流程:
1. 前端从 `GET /api/v1/info` 读到 `captcha.enabled` 和 `captcha.api_endpoint`。 1. 前端从 `GET /api/v1/info` 读到 `captcha.enabled` 和 `captcha.api_endpoint`。
@@ -180,10 +188,11 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
## 12. 设置 ## 12. 设置
频道设置对服务端可见,服务端据此执行: 频道设置对服务端可见,服务端据此执行。`name` 是共享频道名,所有加入者都从频道信息中读取:
| 设置 | 默认 | 范围 | | 设置 | 默认 | 范围 |
|---|---|---| |---|---|---|
| `name` | 无 | 可选,最多 48 个字符;所有频道成员和服务端可见 |
| `seats` | 2 | 1..64(服务端可配置上限) | | `seats` | 2 | 1..64(服务端可配置上限) |
| `members_can_upload` | true | | | `members_can_upload` | true | |
| `delete_policy` | `own` | `owner_only` / `own` / `anyone` | | `delete_policy` | `own` | `owner_only` / `own` / `anyone` |
@@ -320,3 +329,28 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
- 协议版本在封装的 `ver` 字段和 HKDF 的 `info`(`webdrop/v1/...`)中同时体现。 - 协议版本在封装的 `ver` 字段和 HKDF 的 `info`(`webdrop/v1/...`)中同时体现。
- `test-vectors/` 提供固定输入、固定输出:密钥派生、令牌、加解密、padding、封装编解码,以及篡改、重排、截断、换频道的负例,必须全部通过。 - `test-vectors/` 提供固定输入、固定输出:密钥派生、令牌、加解密、padding、封装编解码,以及篡改、重排、截断、换频道的负例,必须全部通过。
- 本版**预留**但不实现:口令叠加(Argon2id)、短配对码(PAKE)、多快照槽。 - 本版**预留**但不实现:口令叠加(Argon2id)、短配对码(PAKE)、多快照槽。
## 17. 按账号分发频道密钥
频道内容仍使用共享 K 加密。创建者用自己的账号 P-256 公钥封装 owner 凭据,随创建请求的 key_package 与频道、owner 成员记录在同一事务内保存。邀请时用收件账号公钥封装 member 凭据,服务端原子保存邀请和密钥包;账号接受邀请并成为成员后,可只凭账号 session 从 /accounts/channels 取回自己的密钥包,不需要含密钥的 URL,也不要求邀请者在线。成员包不得包含管理密钥 A。
密钥包沿用邀请的 ECDH、HKDF、AES-GCM,AAD 绑定收件账号与频道 ID。服务端只保存 opaque 密文;成员私钥保存在口令加密的账号保险库。成员登录后解开保险库、取回密钥包,在 Worker 内恢复频道会话;新设备生成独立 S,打开频道时才占设备席位。待接受的邀请不会自动占席位。
撤销成员时同时删除密钥包和席位。此举不能收回已交付的 K 或历史明文;当前没有轮换密钥或前向保密。仅知道频道 ID 或登录账号并不足以获得密钥;新账号必须得到现有持钥 owner 的授权封装。邀请公钥仍来自服务端目录,尚无外部指纹核验。
## 18. 频道加密文件目录
文件密文帧之后,在同一发送队列追加 CONTROL file.published,正文包含 item.id、kind=file、threadId、folderId(可选)、name、mime、size、ts、deviceId、gated。记录与 thread/folder 控制记录共用 webdrop.workspace v1 正文。名称/类型/大小/原始 thread/folder 必须与文件第 0 帧元数据相符。目录允许先显示名称,但不得因此自动下载 GATED 内容。
客户端只有在当前日志存在对应文件帧或 announce 时展示目录;文件墓碑清除目录可见状态,全量重放时不恢复不存在/过期文件。目录记录属于 CONTROL,仍受 4096 条控制记录和频道字节配额限制。文件移动覆盖位置索引,不重新加密原始文件,不改变最初认证的内容元数据。
## 19. 空间内的频道分组
界面顶层“空间”对应协议 channel,界面“频道”对应 thread。沿用 webdrop.workspace v1 CONTROL 正文:group.create {id,name},group.rename {id,name},thread.group {id,groupId}(null 表示未分组);thread.create 可包含 groupId。ID 为 UUID,name 为 1–80 个非控制字符。分组仅组织频道,成员、内容密钥和日志仍归属于空间。旧创建记录不含 groupId,作为未分组频道重放。
## 20. 发送者展示快照
file.published.item 可携带 author {username,displayName,avatar?};另有 item.author {itemId,author} CONTROL,为文本关联展示身份。username 使用当前账号固定用户名,displayName 最多 48 个字符,avatar 仅允许不超过 8000 字符的 PNG/JPEG/WebP data URL。头像在客户端压缩为最多 64×64 后写入加密正文,不改变内容帧、密钥派生或鉴权头。
快照是持钥成员提供的展示资料,不是发送者签名。无 author 的旧记录回退设备名;客户端不得将此类内容标记为当前账号发送。结构记录随既有 CONTROL 生命周期处理,实际消息可见性仍由条目和墓碑决定。
+25 -6
View File
@@ -16,7 +16,7 @@
| 能看到 | 说明 | | 能看到 | 说明 |
|---|---| |---|---|
| 频道 id、模式、设置 | 席位数、权限、TTL、下载上限等,服务端需要据此执行 | | 频道 id、模式、设置 | 包括共享频道名、席位数、权限、TTL、下载上限等,服务端需要据此执行 |
| 帧数量、时间、序号 | 追加顺序由服务端分配 | | 帧数量、时间、序号 | 追加顺序由服务端分配 |
| 密文大小 | 经过档位 padding,见 protocol §6 | | 密文大小 | 经过档位 padding,见 protocol §6 |
| 帧标志 | FINAL、SNAPSHOT、GATED、ENC | | 帧标志 | FINAL、SNAPSHOT、GATED、ENC |
@@ -24,12 +24,13 @@
| 令牌的哈希、终端的哈希 | 不是原值,但每次请求中会短暂看到令牌原值 | | 令牌的哈希、终端的哈希 | 不是原值,但每次请求中会短暂看到令牌原值 |
| 下载次数 | 服务端计数 | | 下载次数 | 服务端计数 |
| 传输层信息 | IP、TLS 指纹、访问时间 | | 传输层信息 | IP、TLS 指纹、访问时间 |
| 账号服务可见 | 用户名、口令 verifier、保险库密文大小和更新时间;不会看到保险库明文、频道密钥或分享链接 |
| 看不到(enc 模式) | 说明 | | 看不到(enc 模式) | 说明 |
|---|---| |---|---|
| 内容 | 文本、JSON、文件字节 | | 内容 | 文本、JSON、文件字节 |
| 文件名、类型、总大小、设备名 | 都在加密的条目元数据里 | | 文件名、类型、总大小、设备名 | 都在加密的条目元数据里 |
| 密钥 K、管理密钥 A | 只在 URL fragment 中,不会发给服务器 | | 密钥 K、管理密钥 A | 由客户端保管,以账号公钥封装的密文可存服务器;可选分享链接使用 fragment |
`raw` 模式下服务端能看到全部负载,界面必须明确提示。 `raw` 模式下服务端能看到全部负载,界面必须明确提示。
@@ -56,16 +57,25 @@
| fragment 外泄 | 取出后立即 `history.replaceState`;`Referrer-Policy: no-referrer`;不把 K 放进查询串 | | fragment 外泄 | 取出后立即 `history.replaceState`;`Referrer-Policy: no-referrer`;不把 K 放进查询串 |
| XSS | 严格 CSP;条目预览在沙箱中渲染;`raw` 内容不内联渲染,下载带 `attachment` 和 `nosniff` | | XSS | 严格 CSP;条目预览在沙箱中渲染;`raw` 内容不内联渲染,下载带 `attachment` 和 `nosniff` |
| 暴力猜频道 id | id 128 位随机 | | 暴力猜频道 id | id 128 位随机 |
| 撞库/截获账号登录 | 客户端 PBKDF2 派生登录凭据,经 HTTPS 传输;服务端另存独立随机 salt 的 PBKDF2 慢哈希,并限流。TLS 或运行终端泄露仍可能暴露可登录凭据 |
| 账号数据库泄露 | 数据库只有加盐慢哈希,不能直接用于登录;弱口令仍可被离线猜测。登录凭据不能直接派生保险库密钥 |
| 账号混用/设备身份混用 | 按账号与访客隔离 IndexedDB,切换时中止后台任务;保险库不包含设备 S,每台设备单独生成 |
| 绕过频道加入规则 | 服务端检查 owner/member 角色、邀请、密码、账号 session 与设备席位;加密选项不会绕开权限 |
| 匿名滥用与 DoS | 创建时的 Cap 验证码(工作量证明由 Cap 提供)、限流、频道配额、条目数和字节数上限 | | 匿名滥用与 DoS | 创建时的 Cap 验证码(工作量证明由 Cap 提供)、限流、频道配额、条目数和字节数上限 |
## 5. 前端信任根(残余风险) ## 5. 前端信任根(残余风险)
邀请密文使用收件账号的 P-256 公钥派生 AES-GCM 密钥,私钥在密码保护的保险库中。但首次邀请公钥来自服务端目录,目前没有外部指纹核验:恶意服务端替换公钥会影响新邀请的保密性。此限制与已有频道内容的密钥/帧认证边界不同。
端到端加密的前提是浏览器执行的是正确的代码。如果同一个被攻破的后端同时下发前端,它可以换成偷 K 的页面。 端到端加密的前提是浏览器执行的是正确的代码。如果同一个被攻破的后端同时下发前端,它可以换成偷 K 的页面。
第一版的缓解: 隔离部署的缓解:
- 前端静态资源托管在**独立于 API 的域名**,API 节点被攻破不影响前端。 - 前端静态资源托管在**独立于 API 的域名**,API 节点被攻破不影响前端代码。
- 所有脚本使用 SRI,构建产物带版本哈希,发布时公布哈希。 - 可由独立托管方校验脚本 SRI 与构建哈希;前端托管方被攻破的风险仍然存在。
- 前端托管方被攻破的风险仍然存在。
嵌入式部署的取舍:
- `WebDrop.Frontend` 将 Vite dist 嵌入后端程序集,让 API 以同源方式提供页面。
- 这只简化部署,不增强前端与后端的隔离。若部署主机或镜像被控制,攻击者仍可替换页面并窃取用户后续输入的密钥。
后续:发布签名加 PWA 校验、CLI 客户端(协议不依赖浏览器,第三方可自行实现)。 后续:发布签名加 PWA 校验、CLI 客户端(协议不依赖浏览器,第三方可自行实现)。
@@ -78,6 +88,8 @@
- **不提供不可否认性**:帧没有逐发送者签名,所有成员共享同一密钥。 - **不提供不可否认性**:帧没有逐发送者签名,所有成员共享同一密钥。
- **不防已被攻破的终端**。 - **不防已被攻破的终端**。
- **不做口令叠加、后量子**,留待后续版本。 - **不做口令叠加、后量子**,留待后续版本。
- **账号密码不是频道访问控制**。账号保险库只同步持有的资源链接;拿到频道链接的人仍按频道自身能力访问。
- **无密码重置**。忘记密码会失去保险库解密能力;未来若需要找回,须增加独立恢复密钥,而不能由服务端重置后解密旧保险库。
## 7. 服务端内存与日志的要求 ## 7. 服务端内存与日志的要求
@@ -85,3 +97,10 @@
- 令牌只做哈希比对,使用常量时间比较,不写日志。 - 令牌只做哈希比对,使用常量时间比较,不写日志。
- 日志不记录请求头、请求体、完整 URL;频道 id 在日志中截断或哈希。 - 日志不记录请求头、请求体、完整 URL;频道 id 在日志中截断或哈希。
- 错误信息不回显令牌和任何用户提交的字节。 - 错误信息不回显令牌和任何用户提交的字节。
## 8. 账号设备与本机缓存
同一账号的各设备拥有独立 sessions 和频道 S。撤销设备会删除该设备 sessions 与频道席位;不会移除账号成员或其他设备。撤销不能擦除已下载数据、已持有的频道 K 或被复制的账号私钥。当前未实现密钥轮换,不声称前向保密。
本机记住登录允许本浏览器使用者访问账号,退出时删除自动恢复凭据。OPFS 存储解密后临时文件,正常退出账号时清理;浏览器崩溃可能留下本机缓存,它不属于服务端密文。
+72 -76
View File
@@ -74,54 +74,17 @@ blur 的性能约束:
## 6. 布局与页面 ## 6. 布局与页面
WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,不按「谁发的」;发送设备只作为一行小字。 WebDrop 是**暂存区**,频道工作区:聊天按 thread 展示,文件按 thread/文件夹组织;发送设备作为来源信息。
### 6.1 手机(< 900px):最新 + 分类 + 底部动作栏 ### 6.1 手机:频道导航与聊天/文件切换
``` 顶栏下显示账号频道导航与当前频道的 threads。新建频道自动创建“常规”thread,成员可直接聊天和上传。下方用“聊天 / 文件”切换同一 thread 的内容,聊天输入位于底部;文件区与桌面共用目录路径、上传、文件夹、排序、移动和预览组件。
┌────────────────────────────┐
│▒ abc123 🔒加密 ●●○ 2/3 🔍 ◐ ⇧│ ← 模糊顶栏
├────────────────────────────┤
│ 最新 │ 最近一项放大,拇指区一键取走
│ [图标] 屏幕录制.webm │
│ iPhone · 14:20 · 1.8 MB │
│ [ 预览 ] [ 下载 ] │ 文本则是 [ 复制 ]
├────────────────────────────┤
│ ▸ 便签 待办… │ 折叠,点开编辑
├────────────────────────────┤
│ [全部][文件][文本][图片]… │ 类型分组常驻(可横向滑动)
│ [缩略] project-assets.zip ⬇│
│ [缩略] 设计稿.svg v3 有更新 ⬇│
│ [文本] 明天 10 点开会… ⧉ │
├────────────────────────────┤
│▒ 粘贴 文件 相册 拍照 扫码 写 │ ← 模糊底部动作栏
└────────────────────────────┘
```
- 底部是**动作**而不是输入框:粘贴(读剪贴板,文字或图片)、文件、相册、拍照、扫码、写。点「写」才弹出输入。 ### 6.2 电脑:频道与 threads | 聊天 | 线程文件
- 点列表项或「最新」卡打开底部抽屉详情(预览、操作、历史)。
### 6.2 电脑(≥ 900px):列表 + 内联预览 + 窄栏 左侧固定频道导航与 thread 列表,可直接切换已加入频道、新建频道、新建/重命名 thread,并打开账号与设备设置。中间完整高度展示当前 thread 的聊天,底部输入支持文字和文件附件。右侧是资源管理器式文件区:路径面包屑、上传文件、新建文件夹、按名称/大小/时间排序、多选下载、移动、双击预览。
``` 设备与分享收进顶栏弹层,不占第四列。预览在独立弹层打开,不挤占聊天。加密目录先同步文件名、大小与 thread/folder 归属;受限文件必须显式点击“解密并预览文件”或下载后才读取内容,不能因选择列表项或刷新消耗次数。
┌───────────────────────────────────────────────────┬───┐
│ abc123 🔒加密 ●●○ 2/3 🔍 ◐ │ ‹ │
├────────────────────┬──────────────────────────────┤2/3│
│ [粘贴][文件][扫码][写]│ 屏幕录制.webm │ ⇧ │
│ ▸ 便签 待办… │ iPhone · 14:20 · 剩 8 次下载 │ │
│ [全部 文件 文本 …] │ [下载] [删除] │ │
│ ☐ [缩略] zip ⬇ │ ┌────────────────────────┐ │ │
│ ☐ [缩略] 设计稿 ⬇ │ │ 内联预览(视频/图片/目录) │ │ │
│ ☐ [文本] 开会… ⧉ │ └────────────────────────┘ │ │
│ 已选 2 项 [下载] │ 修改历史 v3 当前 / v2 / v1 │ │
└────────────────────┴──────────────────────────────┴───┘
全局:Ctrl+V 粘贴即发送;拖到窗口任意位置即上传
```
- 点左侧一行,右侧内联预览,不弹窗来回开关;默认选中最新一项。
- 多选后可批量下载。
- 设备与分享是低频操作,默认收成右侧窄栏(在线数、分享图标),需要时展开。
- 窗口任意位置 `Ctrl+V` 都能直接发送(在输入框内粘贴时不拦截);拖到窗口任意位置即上传。
### 6.3 顶栏 ### 6.3 顶栏
@@ -138,6 +101,8 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
首页把「临时分享」与「设备频道」作为两个并列入口:前者用于发一段临时文本或文件,后者用于多设备长期同步。 首页把「临时分享」与「设备频道」作为两个并列入口:前者用于发一段临时文本或文件,后者用于多设备长期同步。
主页右上角提供外观菜单,可跟随系统或切换亮色/暗色。右下角分别显示前端和后端的 tag 与短 commit hash,并显示换算为上海时间的构建时间(`yyyy-MM-dd HH:mm`);前端还显示包版本。无法取得 tag 时如实显示「未标记」。
``` ```
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ WebDrop │ │ WebDrop │
@@ -147,6 +112,10 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
│ │ 拖入文件 / 文件名列表 │ │ │ │ 拖入文件 / 文件名列表 │ │
│ │ [选择文件] [粘贴] [创建临时分享]│ │ │ │ [选择文件] [粘贴] [创建临时分享]│ │
│ └────────────────────────────┘ │ │ └────────────────────────────┘ │
│ ┌ 账号与同步 ─────────────────┐ │
│ │ 用户名 / 密码 [登录并同步] │ │
│ │ 资源清单先在本机加密后同步 │ │
│ └────────────────────────────┘ │
│ 我的频道(有才显示) │ │ 我的频道(有才显示) │
│ abc123… 🔒加密 所有者 3 台设备 │ │ abc123… 🔒加密 所有者 3 台设备 │
│ ┌ 设备频道 ───────────────────┐ │ │ ┌ 设备频道 ───────────────────┐ │
@@ -159,9 +128,14 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
| 操作 | 结果 | | 操作 | 结果 |
|---|---| |---|---|
| 快速分享(输入文本、拖入、粘贴、选择、相册、拍照) | 先在首页编辑临时文本并收集文件;点「创建临时分享」后建立加密草稿并带入内容;点「完成分享」后生成链接与二维码 | | 快速分享(输入文本、拖入、粘贴、选择、相册、拍照) | 先在首页编辑临时文本并收集文件;点「创建临时分享」后建立加密草稿并带入内容;点「完成分享」后生成链接与二维码 |
| 新建频道 | 抽屉:模式(加密 / 明文)、设备数、保留时间、允许成员上传;创建后直接展示分享面板与二维码 | | 新建频道 | 登录后创建,自动成为该频道所有者;可选择是否端到端加密,默认启用;设备数、TTL、受限下载次数和上传权限仍用同一频道模型 |
| 加入频道 | 扫码或粘贴链接 → 加入预览 → 加入 | | 加入频道 | 扫码或粘贴链接 → 加入预览 → 加入 |
| 我的频道 | 点一行进入;垃圾桶只是从本机移除,**不会删除频道**,需二次确认 | | 我的频道 | 点一行进入;垃圾桶从当前账号/访客资源清单移除,**不会删除频道**,需二次确认;账号清单移除会同步到其他设备 |
| 账号与同步 | 用户名密码注册或登录;本地频道/分享凭据在 Worker 加密后同步,另一台设备登录后恢复;退出时撤销会话 |
账号密码不提供找回流程。忘记密码会导致账号保险库无法解密;界面需在注册区域明确提示。本机记住登录通过 IndexedDB 保存 session 和不可导出的保险库 CryptoKey,刷新后恢复身份;退出或设备被撤销后清除。
未登录访问首页时先显示注册/登录页;收到的频道或分享链接保持访客入口。频道加入页按当前规则显示密码输入或登录提示。右上角频道设置集中管理加入方式、陌生人开关、频道密码、成员角色、邀请与撤销,不另设权限入口。
| 路由 | 页面 | | 路由 | 页面 |
|---|---| |---|---|
@@ -172,37 +146,17 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
频道分享面板与分享页里的二维码只给**成员链接**;所有者链接含管理权限,只提供复制,不出二维码。二维码固定深色码点、浅色底,暗色主题下也不反转,否则很多扫码器识别不了。 频道分享面板与分享页里的二维码只给**成员链接**;所有者链接含管理权限,只提供复制,不出二维码。二维码固定深色码点、浅色底,暗色主题下也不反转,否则很多扫码器识别不了。
### 6.4.1 分享(快速分享的结果) ### 6.4.1 便捷分享
**分享和频道是两种东西,底层只维护一套**:分享是频道的一个预设——成员不可上传、删除策略仅所有者,并且创建者在点「完成分享」的那一刻**丢弃所有者密钥**,从此没有房主,任何人(包括创建者)都不能再追加、修改或删除,只能等 TTL 到期。 Share 是独立的只读发布资源,不创建频道成员、threads 或席位。创建者在草稿中添加内容和设置,明确确认后封存;服务端清除 owner_hash,前端丢弃管理密钥,此后只有读取入口。访客打开分享链接直接读取加密清单,下载受限文件时计次数,不经过频道加入流程。详见 protocol.md §15。
``` ### 6.5 频道 thread、文件管理与聊天
草稿(创建者) 已封存(创建者与访客)
┌────────────────────────────────┐ ┌──────────────────────┐
│ 新建分享 · 草稿 [粘贴][文件][写]│ │ 我的分享 · 已封存只读 │
├───────────────┬────────────────┤ │ 分享链接 [复制][二维码]│
│ 内容清单 │ 选中的内容 │ │ 3 项内容 [全部下载] │
│ ▢ a.zip 46MB ✕│ [文件预览] │ │ 〃 开会地址… │
│ 〃 文本 120B ✕ │ [替换文件] │ │ ▢ a.zip [下载] │
│ │ │ └──────────────────────┘
│ │ 文本可直接编辑 │
├───────────────┴────────────────┤
│ 有效期 · 最多访客 · 下载次数 │
│ □ 我已了解,完成后无法再修改 │
│ [ 完成分享 ] │
└────────────────────────────────┘
```
| 阶段 | 说明 | 频道内桌面布局为左侧频道/thread 导航、中央聊天、右侧线程文件管理器。新建频道自动创建“常规”thread;用户可以继续创建和命名其他 thread,在各自 thread 中发言或上传文件。聊天消息显示发送设备名称和时间。
|---|---|
| 草稿 | 左侧列出内容,选择后在右侧预览;创建者还握着所有者密钥,可直接修改文本、替换或移除文件,并调整有效期、最多访客、每个文件下载次数。**此时没有分享链接**,不会被别人看到 |
| 完成分享 | 页面内勾选「完成后会生成分享链接和二维码。此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期」后,按钮才可用;点击即生成链接与二维码 |
| 已封存(创建者) | 只读列表 + 「分享给别人」卡片(链接、二维码、说明) |
| 已封存(访客) | 只读列表,只能预览、下载、复制;「全部下载」按钮 |
- 访客打开链接后先看加入预览,确认才占席位。访客那一端如何识别「分享」:`members_can_upload=false` 即渲染为只读收件页,不需要新协议字段。 左栏按 thread 显示虚拟文件夹与文件。可以创建/重命名 thread、创建/重命名文件夹、多选文件并移动到当前 thread 的文件夹。文件预览仍使用中央预览区;消息不混入文件列表。手机上 thread 和文件树可横向/纵向收缩,聊天输入固定在文件列表下方、上传动作栏上方。
- 创建者的所有者链接不展示、不出二维码。
- 首页分成「我的分享」(草稿 / 已封存 / 收到的,带剩余时间)和「我的频道」。 thread 名、文件夹名和移动操作使用同一套 CONTROL 日志条目同步;端到端加密开启时元数据是密文,关闭时服务端可见。两种传输选项共享相同的线程和目录模型。GATED 文件通过独立加密目录显示名称和归属,内容仅在显式操作后读取;没有目录记录的旧文件仍显示通用占位。
其他弹层: 其他弹层:
@@ -211,7 +165,8 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
| 条目详情 | 手机为底部抽屉,电脑为内联预览区;内容一致 | | 条目详情 | 手机为底部抽屉,电脑为内联预览区;内容一致 |
| 扫码加入 | 相机识别二维码(浏览器自带 `BarcodeDetector`,不支持或无权限时退回「粘贴链接」)→ 加入预览(模式、权限)→ 加入后进入该频道 | | 扫码加入 | 相机识别二维码(浏览器自带 `BarcodeDetector`,不支持或无权限时退回「粘贴链接」)→ 加入预览(模式、权限)→ 加入后进入该频道 |
| 写文本 | 抽屉,`Ctrl+Enter` 发送;首页上用于快速分享 | | 写文本 | 抽屉,`Ctrl+Enter` 发送;首页上用于快速分享 |
| 频道设置 | 尚未实现(席位管理等) | | 频道设置 | 顶栏齿轮打开设置面板;所有者可修改名称、席位、成员上传、保留时间和受限文件下载次数 |
| 设备管理 | 设备面板显示已加入席位数;设备可重命名,所有者可重命名任意设备并移除其他设备 |
扫到的链接含密钥,只放在内存里,不写日志、不持久化。 扫到的链接含密钥,只放在内存里,不写日志、不持久化。
@@ -292,7 +247,14 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
| 历史可读 | 加入说明处写明「持有链接的人可读取此频道全部历史」 | | 历史可读 | 加入说明处写明「持有链接的人可读取此频道全部历史」 |
| 元数据 | 创建与设置页注明「不隐藏 IP 与访问时间」 | | 元数据 | 创建与设置页注明「不隐藏 IP 与访问时间」 |
### 7.5 空与错误状态 ### 7.5 频道文件预览与下载限制
- 新上传文件默认允许单帧内容直接随频道同步。加入频道的访客可直接看到文件名、类型、大小;PNG 等图片和 TXT 等文本可直接预览,不消耗下载次数。文件名与类型仍只存在于加密帧中。
- 超出单帧容量的文件保持 GATED,列表不自动读取内容。访客主动打开预览时才取回文件,并消耗一次下载次数;取回后显示解密出的文件名、类型和大小。EXE 等不支持内联预览的类型仍提供下载。
- 上传者可关闭直接预览并为文件设置下载次数上限。
- 设备标签优先显示浏览器提供的机型和系统版本,例如「Pixel 9 (Android 16) · A1B2C3D4」「Windows 11 · 1234ABCD」。浏览器没有暴露具体机型时显示能识别的平台名称;保留端点短标识以区分同型号设备。
### 7.6 空与错误状态
| 码 | 页面 | | 码 | 页面 |
|---|---| |---|---|
@@ -309,8 +271,10 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
| 发送文本 | 点「写」弹出输入,`Ctrl+Enter` 发送 | | 发送文本 | 点「写」弹出输入,`Ctrl+Enter` 发送 |
| 粘贴 | 窗口任意位置 `Ctrl+V` 直接发送;手机点「粘贴」读取剪贴板(无权限时退回「写」)。文件或图片直接入队,先出现「传输中」条目 | | 粘贴 | 窗口任意位置 `Ctrl+V` 直接发送;手机点「粘贴」读取剪贴板(无权限时退回「写」)。文件或图片直接入队,先出现「传输中」条目 |
| 拖放 | 桌面整页接收拖放;拖入时出现覆盖提示,不改变布局 | | 拖放 | 桌面整页接收拖放;拖入时出现覆盖提示,不改变布局 |
| 文件上传 | 上传前可设置是否让单帧文件直接同步预览,以及受限文件的下载次数上限 |
| 频道设置 | 顶栏齿轮打开;设置通过现有 `PATCH /channels/{id}/settings` 更新,服务端广播变更给在线成员 |
| 复制 | 点击复制,按钮短暂变为「已复制」,不弹 Toast | | 复制 | 点击复制,按钮短暂变为「已复制」,不弹 Toast |
| 删除 | 单击删除后条目消失并给 5 秒「撤销」(撤销只在本地延迟提交删除) | | 删除 | 确认删除后提交删除,并同步频道墓碑 |
| 新条目到达 | 若用户已滚到底部则自动跟随,否则底部出现「N 条新内容」 | | 新条目到达 | 若用户已滚到底部则自动跟随,否则底部出现「N 条新内容」 |
| 触控 | 可点击区域 ≥ 44px;底栏避开安全区(`env(safe-area-inset-*)`) | | 触控 | 可点击区域 ≥ 44px;底栏避开安全区(`env(safe-area-inset-*)`) |
@@ -362,5 +326,37 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
| 相机 | 「拍照」用 `<input capture="environment">`,iOS 与 Android 都会直接拉起相机;扫码在 Android Chrome 用 `BarcodeDetector`,iOS Safari 不支持,提示用系统相机扫码后打开链接,或粘贴链接。相机、剪贴板读取都要求 https(`localhost` 除外) | | 相机 | 「拍照」用 `<input capture="environment">`,iOS 与 Android 都会直接拉起相机;扫码在 Android Chrome 用 `BarcodeDetector`,iOS Safari 不支持,提示用系统相机扫码后打开链接,或粘贴链接。相机、剪贴板读取都要求 https(`localhost` 除外) |
| 剪贴板 | iOS 读取时会弹出系统「粘贴」确认,Android 会要求权限;被拒绝时退回「写」 | | 剪贴板 | iOS 读取时会弹出系统「粘贴」确认,Android 会要求权限;被拒绝时退回「写」 |
| 图片格式 | iPhone 的 HEIC 照片在非 Safari 浏览器里无法显示,预览失败时提示直接下载 | | 图片格式 | iPhone 的 HEIC 照片在非 Safari 浏览器里无法显示,预览失败时提示直接下载 |
| 下载 | iOS 上 `a[download]` 配合 blob 会弹出存储菜单;大文件要走 Service Worker 流式下载(架构 §6,阶段 4) | | 下载 | iOS 上 `a[download]` 配合 blob 会弹出存储菜单;大文件优先走 OPFS 临时存储;不支持时 Blob 回退最多 64 MiB |
## 13. 空间、频道与频道分组
界面层级统一为「空间 → 频道分组(可选)→ 频道 → 聊天与文件夹」。原顶层频道对用户称为空间,原 thread 对用户称为频道。新建空间自动创建“常规”频道。空间负责成员、邀请、设备席位和加密边界;频道分组只组织导航,不改变权限或加密密钥。
左侧上段显示账号与空间列表,下段按分组展示频道。支持新建/重命名分组、折叠分组、在分组内新建频道、将已有频道移入其他分组或移回未分组。旧 thread 自动作为未分组频道展示。中间显示当前频道聊天,右侧显示当前频道文件夹和文件。
空间设置集中管理成员、邀请和空间权限;频道管理提供名称与所属分组。分组名及归属通过已有加密 CONTROL 日志同步,刷新与其他设备保持一致。协议/API 内部保留 channels/thread_id 等标识,避免因界面术语变更重建已有数据。
## 14. 文件管理器交互
文件区支持单击选中、Ctrl/Command 多选、Shift 连选、双击预览、右键菜单和键盘 Enter/Delete/Ctrl+A/Escape。文件夹单击选中、双击或菜单“打开文件夹”进入;面包屑用于快速返回。文件行提供更多菜单按钮,触屏也能访问操作。
右键文件提供预览、下载、复制名称、移动和删除;文件夹提供打开和重命名;空白区域提供上传、新建文件夹、全选。菜单和悬停卡通过 portal 挂载到 body,贴近指针且限制在视口内,支持 Escape、外部点击和键盘导航。
文件可拖入当前频道的文件夹或面包屑目录,多选文件可一起移动;跨空间/频道的内部拖放拒绝。外部文件可拖入指定文件夹上传。内部拖拽移动仅追加目录控制记录,不重新加密或拉取文件。
悬停卡延迟显示名称、类型、大小、时间;已同步且非 GATED 的小图片/文本可显示缩略预览。GATED、上传中或校验失败的文件不会自动取内容,悬停不消耗下载次数。右侧文件区与聊天之间可拖动分隔条调整宽度,分隔条也支持键盘左右键。
## 15. 频道中的文件消息与发送者
聊天时间线同时显示文本和已发布的文件,文件消息使用头像、发送者名称、时间和文件卡片,卡片提供预览和下载。当前账号发出的消息靠右,其他人的消息靠左;没有头像时显示名称首字。上传中显示进度和状态,受限文件不自动加载内容或媒体缩略图。
发送者名称优先使用发送时的个人名称,回退固定用户名;设备名保留在提示中。姓名和头像快照随空间加密日志同步,其他账号和设备刷新后仍能看到原上传者。旧记录没有发送者快照时回退设备来源,不推测为当前用户。
头像缩略图在 Worker 内生成,最大 64×64、编码后不超过 8 KB,仅使用 PNG/JPEG/WebP data URL;不使用第三方头像链接。圆形头像使用专门的 --radius-avatar token,消息气泡仍遵守现有圆角规范。
## 16. 记住账号和密码
登录/注册表单提供“记住账号和密码(本机)”选项,默认勾选。成功验证后才保存本机加密凭据,下次需要登录时自动填充用户名和密码;有效会话仍优先自动恢复。取消勾选时,只保留当前页面会话,后台同步不能再次写入自动登录数据。
正常退出登录清除会话,但保留用户明确选择保存的填充信息。“清除本机保存”同时清除保存的账号密码和自动恢复会话;如果当前已登录,可以继续使用当前页面,刷新后需重新登录。会话被服务端拒绝时清除本机恢复资料。界面提示只在自己的设备使用记住功能,并兼容浏览器自带密码管理器。
-1
View File
@@ -92,7 +92,6 @@ ws.onmessage = async (m) => {
| `itemId` | 重试和续传必须复用同一个 id | | `itemId` | 重试和续传必须复用同一个 id |
| `fromFrame` | 上传断点续传:从第 k 帧开始产出(字节与完整加密完全相同)。仅支持 `Blob` / `Uint8Array` | | `fromFrame` | 上传断点续传:从第 k 帧开始产出(字节与完整加密完全相同)。仅支持 `Blob` / `Uint8Array` |
| `ts`, `dev` | 覆盖时间戳和设备名 | | `ts`, `dev` | 覆盖时间戳和设备名 |
| `label` | 仅 raw 模式:`X-WD-Label` |
**GATED 的判断**:来源长度已知时创建计划时就确定;来源是流时,要产出第 0 帧之后 `plan.gated` 和 `frame0Headers` 才确定,请在拿到第 0 帧后再读取。 **GATED 的判断**:来源长度已知时创建计划时就确定;来源是流时,要产出第 0 帧之后 `plan.gated` 和 `frame0Headers` 才确定,请在拿到第 0 帧后再读取。
+1
View File
@@ -13,6 +13,7 @@ export const Flags = {
Snapshot: 2, Snapshot: 2,
Gated: 4, Gated: 4,
Enc: 8, Enc: 8,
Control: 16,
} as const; } as const;
/** 档位 padding 的明文长度(protocol §6)。 */ /** 档位 padding 的明文长度(protocol §6)。 */
+2 -2
View File
@@ -49,7 +49,7 @@ export interface Envelope {
export function parseEnvelope(data: Uint8Array): Envelope { export function parseEnvelope(data: Uint8Array): Envelope {
if (data.length < HEADER_SIZE || data[0] !== 1) throw new IntegrityError('bad_envelope'); if (data.length < HEADER_SIZE || data[0] !== 1) throw new IntegrityError('bad_envelope');
const flags = data[1] as number; const flags = data[1] as number;
if ((flags & 0xf0) !== 0) throw new IntegrityError('bad_envelope'); if ((flags & 0xe0) !== 0) throw new IntegrityError('bad_envelope');
const payloadLength = readU32(data, 22); const payloadLength = readU32(data, 22);
if (data.length !== HEADER_SIZE + payloadLength) throw new IntegrityError('bad_envelope'); if (data.length !== HEADER_SIZE + payloadLength) throw new IntegrityError('bad_envelope');
@@ -109,7 +109,7 @@ export interface SealParams {
*/ */
export async function sealFrame(itemKey: CryptoKey, channelId: Uint8Array, p: SealParams): Promise<Bytes> { export async function sealFrame(itemKey: CryptoKey, channelId: Uint8Array, p: SealParams): Promise<Bytes> {
if ((p.flags & Flags.Enc) === 0) throw new RangeError('enc 帧必须带 ENC 标志'); if ((p.flags & Flags.Enc) === 0) throw new RangeError('enc 帧必须带 ENC 标志');
if ((p.flags & 0xf0) !== 0) throw new RangeError('保留标志位必须为 0'); if ((p.flags & 0xe0) !== 0) throw new RangeError('保留标志位必须为 0');
const plaintext = padPlaintext(p.data); const plaintext = padPlaintext(p.data);
const header = concat(new Uint8Array([1, p.flags]), p.itemId, writeU32(p.index), writeU32(plaintext.length + TAG_SIZE)); const header = concat(new Uint8Array([1, p.flags]), p.itemId, writeU32(p.index), writeU32(plaintext.length + TAG_SIZE));
+1 -1
View File
@@ -7,7 +7,7 @@ import { Labels } from './constants.js';
import { utf8, type Bytes } from './bytes.js'; import { utf8, type Bytes } from './bytes.js';
export { encodeEnvelope, frameAad, frameIv, padPlaintext, parseEnvelope, sealFrame, tierFor, unpadPlaintext } from './frame.js'; export { encodeEnvelope, frameAad, frameIv, padPlaintext, parseEnvelope, sealFrame, tierFor, unpadPlaintext } from './frame.js';
export { encodeMeta, decodeFirstFrame, planEncItem, planRawItem } from './item.js'; export { encodeMeta, decodeFirstFrame, planItem } from './item.js';
export { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256 } from './kdf.js'; export { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256 } from './kdf.js';
async function hkdfBytes(ikm: Uint8Array, salt: Uint8Array, label: string): Promise<Bytes> { async function hkdfBytes(ikm: Uint8Array, salt: Uint8Array, label: string): Promise<Bytes> {
+46 -43
View File
@@ -1,4 +1,4 @@
import { Flags, MAX_FRAME_DATA, RAW_MAX_PAYLOAD, type ItemKind } from './constants.js'; import { Flags, MAX_FRAME_DATA, type ItemKind } from './constants.js';
import { concat, readU16, toBytes, tryDecodeUtf8, utf8, writeU16, toBase64Url, type Bytes } from './bytes.js'; import { concat, readU16, toBytes, tryDecodeUtf8, utf8, writeU16, toBase64Url, type Bytes } from './bytes.js';
import { IntegrityError, LimitError } from './errors.js'; import { IntegrityError, LimitError } from './errors.js';
@@ -14,6 +14,10 @@ export interface ItemMeta {
ts: number; ts: number;
/** 设备名。 */ /** 设备名。 */
dev?: string; dev?: string;
/** WebDrop 频道中的 thread ID,加密元数据。 */
threadId?: string;
/** 文件所在的虚拟文件夹 ID,加密元数据。 */
folderId?: string;
} }
const KINDS: readonly ItemKind[] = ['text', 'json', 'file', 'manifest', 'other']; const KINDS: readonly ItemKind[] = ['text', 'json', 'file', 'manifest', 'other'];
@@ -26,6 +30,8 @@ export function encodeMeta(meta: ItemMeta): Bytes {
if (meta.size !== undefined) o.size = meta.size; if (meta.size !== undefined) o.size = meta.size;
o.ts = meta.ts; o.ts = meta.ts;
if (meta.dev !== undefined) o.dev = meta.dev; if (meta.dev !== undefined) o.dev = meta.dev;
if (meta.threadId !== undefined) o.thread_id = meta.threadId;
if (meta.folderId !== undefined) o.folder_id = meta.folderId;
const json = utf8(JSON.stringify(o)); const json = utf8(JSON.stringify(o));
if (json.length > 0xffff) throw new LimitError('条目元数据过大'); if (json.length > 0xffff) throw new LimitError('条目元数据过大');
@@ -64,6 +70,12 @@ export function decodeFirstFrame(data: Uint8Array): { meta: ItemMeta; content: B
if (typeof v !== 'string') throw new IntegrityError('bad_meta'); if (typeof v !== 'string') throw new IntegrityError('bad_meta');
meta[key] = v; meta[key] = v;
} }
for (const [wire, key] of [['thread_id', 'threadId'], ['folder_id', 'folderId']] as const) {
const v = o[wire];
if (v === undefined) continue;
if (typeof v !== 'string' || v.length > 80) throw new IntegrityError('bad_meta');
meta[key] = v;
}
if (o.size !== undefined) { if (o.size !== undefined) {
if (typeof o.size !== 'number' || !Number.isSafeInteger(o.size) || o.size < 0) throw new IntegrityError('bad_meta'); if (typeof o.size !== 'number' || !Number.isSafeInteger(o.size) || o.size < 0) throw new IntegrityError('bad_meta');
meta.size = o.size; meta.size = o.size;
@@ -76,6 +88,7 @@ export function decodeFirstFrame(data: Uint8Array): { meta: ItemMeta; content: B
export type ContentSource = Uint8Array | Blob | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>; export type ContentSource = Uint8Array | Blob | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>;
export interface PlanOptions { export interface PlanOptions {
control?: boolean;
/** 指定条目 id(重试、续传时必须复用同一个);默认随机生成。 */ /** 指定条目 id(重试、续传时必须复用同一个);默认随机生成。 */
itemId?: Uint8Array; itemId?: Uint8Array;
/** 作为频道的「最新快照」。快照必须是单帧,所以内容不能超过一帧。 */ /** 作为频道的「最新快照」。快照必须是单帧,所以内容不能超过一帧。 */
@@ -109,7 +122,7 @@ export interface ItemPlan {
* 要等第 0 帧产出之后才确定,所以请在拿到第 0 帧之后再读取它和 frame0Headers。 * 要等第 0 帧产出之后才确定,所以请在拿到第 0 帧之后再读取它和 frame0Headers。
*/ */
readonly gated: boolean; readonly gated: boolean;
/** 第 0 帧要带的附加请求头(X-WD-Max-Downloads / X-WD-Label)。 */ /** 第 0 帧要带的附加请求头(X-WD-Max-Downloads)。 */
frame0Headers: Record<string, string>; frame0Headers: Record<string, string>;
/** 按序产出加密后的帧。同一个计划只能迭代一次。 */ /** 按序产出加密后的帧。同一个计划只能迭代一次。 */
frames: AsyncGenerator<EncodedFrame, void, void>; frames: AsyncGenerator<EncodedFrame, void, void>;
@@ -134,9 +147,24 @@ async function* chunksOf(source: ContentSource): AsyncGenerator<Uint8Array, void
return; return;
} }
if (typeof Blob !== 'undefined' && source instanceof Blob) { if (typeof Blob !== 'undefined' && source instanceof Blob) {
if (typeof source.stream === 'function') {
yield* chunksOf(source.stream()); yield* chunksOf(source.stream());
return; return;
} }
// Older Safari releases don't expose Blob.stream(). Read bounded slices
// instead of materializing the complete file in memory.
const sliceSize = 256 * 1024;
for (let offset = 0; offset < source.size; offset += sliceSize) {
const part = source.slice(offset, Math.min(offset + sliceSize, source.size));
if (typeof part.arrayBuffer === 'function') {
yield new Uint8Array(await part.arrayBuffer());
} else {
yield await readBlobPart(part);
}
}
return;
}
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) { if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
// 不依赖 ReadableStream 的异步迭代器(旧版 Safari 没有) // 不依赖 ReadableStream 的异步迭代器(旧版 Safari 没有)
const reader = source.getReader(); const reader = source.getReader();
@@ -153,6 +181,18 @@ async function* chunksOf(source: ContentSource): AsyncGenerator<Uint8Array, void
yield* source as AsyncIterable<Uint8Array>; yield* source as AsyncIterable<Uint8Array>;
} }
function readBlobPart(blob: Blob): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => reader.result instanceof ArrayBuffer
? resolve(new Uint8Array(reader.result))
: reject(new TypeError('文件读取失败'));
reader.onerror = () => reject(reader.error ?? new TypeError('文件读取失败'));
reader.onabort = () => reject(new DOMException('文件读取已取消', 'AbortError'));
reader.readAsArrayBuffer(blob);
});
}
interface Chunk { interface Chunk {
data: Bytes; data: Bytes;
last: boolean; last: boolean;
@@ -229,11 +269,12 @@ function resolveGating(opts: PlanOptions, fitsInOneFrame: boolean | undefined):
* enc 模式:把内容加密成一个条目(protocol §7)。 * enc 模式:把内容加密成一个条目(protocol §7)。
* 小内容单帧、非 GATED;放不下就拆成多帧并标记 GATED。第 0 帧携带元数据。 * 小内容单帧、非 GATED;放不下就拆成多帧并标记 GATED。第 0 帧携带元数据。
*/ */
export function planEncItem( export function planItem(
seal: (p: { flags: number; itemId: Uint8Array; index: number; data: Uint8Array }) => Promise<Bytes>, seal: (p: { flags: number; itemId: Uint8Array; index: number; data: Uint8Array }) => Promise<Bytes>,
source: ContentSource, source: ContentSource,
meta: ItemMeta, meta: ItemMeta,
opts: PlanOptions = {}, opts: PlanOptions = {},
encrypted = true,
): ItemPlan { ): ItemPlan {
const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId(); const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId();
const maxData = opts.maxFrameData ?? MAX_FRAME_DATA; const maxData = opts.maxFrameData ?? MAX_FRAME_DATA;
@@ -254,9 +295,10 @@ export function planEncItem(
if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads); if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads);
const snapshot = opts.snapshot === true; const snapshot = opts.snapshot === true;
if (opts.control && (snapshot || gated)) throw new RangeError('控制条目必须是非快照的单帧');
async function* frames(): AsyncGenerator<EncodedFrame, void, void> { async function* frames(): AsyncGenerator<EncodedFrame, void, void> {
const base = (gatedNow: boolean) => (gatedNow ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | Flags.Enc; const base = (gatedNow: boolean) => (gatedNow ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | (opts.control ? Flags.Control : 0) | (encrypted ? Flags.Enc : 0);
const emit = async (index: number, last: boolean, data: Uint8Array, gatedNow: boolean): Promise<EncodedFrame> => ({ const emit = async (index: number, last: boolean, data: Uint8Array, gatedNow: boolean): Promise<EncodedFrame> => ({
index, index,
final: last, final: last,
@@ -299,42 +341,3 @@ export function planEncItem(
return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() }; return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() };
} }
/**
* raw 模式:负载原样,没有加密、没有元数据。单帧放不下时拆成 GATED 多帧。
* 注意 raw 内容对服务端完全可见。
*/
export function planRawItem(
build: (p: { flags: number; itemId: Uint8Array; index: number; payload: Uint8Array }) => Bytes,
source: ContentSource,
opts: PlanOptions & { label?: string } = {},
): ItemPlan {
const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId();
const cap = Math.min(opts.maxFrameData ?? RAW_MAX_PAYLOAD, RAW_MAX_PAYLOAD);
const snapshot = opts.snapshot === true;
const size = source instanceof Uint8Array ? source.length : typeof Blob !== 'undefined' && source instanceof Blob ? source.size : undefined;
let gated = resolveGating(opts, size === undefined ? undefined : size <= cap);
const frame0Headers: Record<string, string> = {};
if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads);
if (opts.label !== undefined) frame0Headers['X-WD-Label'] = opts.label;
async function* frames(): AsyncGenerator<EncodedFrame, void, void> {
let index = 0;
for await (const chunk of rechunk(source, cap, cap)) {
if (index === 0) {
if (chunk.last && opts.gated !== true && opts.maxDownloads === undefined) gated = false;
else if (!chunk.last) {
if (snapshot) throw new LimitError('快照必须是单帧,内容超过了一帧');
gated = true;
}
}
const flags = (gated ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | (chunk.last ? Flags.Final : 0);
yield { index, final: chunk.last, bytes: build({ flags, itemId: idBytes, index, payload: chunk.data }) };
index++;
}
}
return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() };
}
+4 -4
View File
@@ -14,7 +14,7 @@ export interface ReaderContext {
itemKey(itemId: Uint8Array): Promise<CryptoKey>; itemKey(itemId: Uint8Array): Promise<CryptoKey>;
} }
export type ReceivedKind = ItemKind | 'raw'; export type ReceivedKind = ItemKind;
export interface ReceivedItem { export interface ReceivedItem {
itemId: string; itemId: string;
@@ -124,7 +124,7 @@ export class ItemReader {
if (isEncFrame !== (this.#ctx.mode === 'enc')) throw new IntegrityError('wrong_mode'); if (isEncFrame !== (this.#ctx.mode === 'enc')) throw new IntegrityError('wrong_mode');
if (this.#done.has(itemId)) return []; if (this.#done.has(itemId)) return [];
const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot); const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot | Flags.Control);
const gated = (env.flags & Flags.Gated) !== 0; const gated = (env.flags & Flags.Gated) !== 0;
const isFinal = (env.flags & Flags.Final) !== 0; const isFinal = (env.flags & Flags.Final) !== 0;
// 非 GATED 条目必须是单帧且 FINAL(协议规则,服务端也强制) // 非 GATED 条目必须是单帧且 FINAL(协议规则,服务端也强制)
@@ -156,7 +156,7 @@ export class ItemReader {
let meta = state?.meta; let meta = state?.meta;
let content = data; let content = data;
if (this.#ctx.mode === 'enc' && env.frameIndex === 0) { if (env.frameIndex === 0) {
const first = decodeFirstFrame(data); const first = decodeFirstFrame(data);
meta = first.meta; meta = first.meta;
content = first.content; content = first.content;
@@ -179,7 +179,7 @@ export class ItemReader {
const item: ReceivedItem = { const item: ReceivedItem = {
itemId, itemId,
seq, seq,
kind: this.#ctx.mode === 'raw' ? 'raw' : (state.meta?.kind ?? 'other'), kind: state.meta?.kind ?? 'other',
snapshot: (itemFlags & Flags.Snapshot) !== 0, snapshot: (itemFlags & Flags.Snapshot) !== 0,
gated, gated,
complete, complete,
+21 -16
View File
@@ -4,7 +4,7 @@ import { CapabilityError, IntegrityError, LimitError, LinkError, ModeMismatchErr
import { encodeEnvelope, sealFrame } from './frame.js'; import { encodeEnvelope, sealFrame } from './frame.js';
import { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256, type ChannelKeys } from './kdf.js'; import { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256, type ChannelKeys } from './kdf.js';
import { buildLink, parseLink, type ParsedLink } from './link.js'; import { buildLink, parseLink, type ParsedLink } from './link.js';
import { planEncItem, planRawItem, type ContentSource, type ItemMeta, type ItemPlan, type PlanOptions } from './item.js'; import { planItem, type ContentSource, type ItemMeta, type ItemPlan, type PlanOptions } from './item.js';
import { ItemReader, type ReaderContext } from './reader.js'; import { ItemReader, type ReaderContext } from './reader.js';
import { decryptItemStream, type DecryptStreamOptions, type ItemDecodeResult } from './stream.js'; import { decryptItemStream, type DecryptStreamOptions, type ItemDecodeResult } from './stream.js';
@@ -12,6 +12,9 @@ export type AuthRole = 'read' | 'write' | 'owner';
/** 频道设置,字段名与 API 一致(snake_case)。 */ /** 频道设置,字段名与 API 一致(snake_case)。 */
export interface ChannelSettings { export interface ChannelSettings {
join_policy?: 'open' | 'invite';
allow_guests?: boolean;
name?: string;
seats?: number; seats?: number;
members_can_upload?: boolean; members_can_upload?: boolean;
delete_policy?: 'owner_only' | 'own' | 'anyone'; delete_policy?: 'owner_only' | 'own' | 'anyone';
@@ -56,8 +59,10 @@ export interface PlanItemOptions extends PlanOptions {
ts?: number; ts?: number;
/** 覆盖设备名。默认使用 session.deviceName。 */ /** 覆盖设备名。默认使用 session.deviceName。 */
dev?: string; dev?: string;
/** 仅 raw 模式:条目标签,作为 X-WD-Label 随第 0 帧发送(明文,服务端可见)。enc 模式下忽略,名字放在加密的元数据里。 */ /** Workspace thread ID, encoded only in encrypted item metadata. */
label?: string; threadId?: string;
/** Virtual folder ID for files, encoded only in encrypted item metadata. */
folderId?: string;
} }
export interface FileMetaOverrides { export interface FileMetaOverrides {
@@ -255,22 +260,17 @@ export class ChannelSession {
#meta(kind: ItemMeta['kind'], opts: PlanItemOptions, extra: Partial<ItemMeta> = {}): ItemMeta { #meta(kind: ItemMeta['kind'], opts: PlanItemOptions, extra: Partial<ItemMeta> = {}): ItemMeta {
const meta: ItemMeta = { kind, ts: opts.ts ?? Date.now(), ...extra }; const meta: ItemMeta = { kind, ts: opts.ts ?? Date.now(), ...extra };
if (opts.threadId !== undefined) meta.threadId = opts.threadId;
if (opts.folderId !== undefined) meta.folderId = opts.folderId;
const dev = opts.dev ?? this.deviceName; const dev = opts.dev ?? this.deviceName;
if (dev !== undefined) meta.dev = dev; if (dev !== undefined) meta.dev = dev;
return meta; return meta;
} }
#plan(source: ContentSource, meta: ItemMeta, opts: PlanItemOptions): ItemPlan { #plan(source: ContentSource, meta: ItemMeta, opts: PlanItemOptions): ItemPlan {
if (this.mode === 'enc') { return planItem(async (p) => this.mode === 'enc'
return planEncItem( ? sealFrame(await this.itemKey(p.itemId), this.idBytes, p)
async (p) => sealFrame(await this.itemKey(p.itemId), this.idBytes, p), : encodeEnvelope(p.flags, p.itemId, p.index, p.data), source, meta, opts, this.mode === 'enc');
source,
meta,
opts,
);
}
// raw:没有元数据,内容原样放进负载
return planRawItem((p) => encodeEnvelope(p.flags, p.itemId, p.index, p.payload), source, opts);
} }
encryptBytes(content: Uint8Array, meta: Partial<ItemMeta> & { kind?: ItemMeta['kind'] } = {}, opts: PlanItemOptions = {}): ItemPlan { encryptBytes(content: Uint8Array, meta: Partial<ItemMeta> & { kind?: ItemMeta['kind'] } = {}, opts: PlanItemOptions = {}): ItemPlan {
@@ -331,11 +331,16 @@ export class ChannelSession {
// ───────────────────────── 设备名(protocol §10) ───────────────────────── // ───────────────────────── 设备名(protocol §10) ─────────────────────────
/** /**
* 加密设备名,作为 PUT /seats/me 的请求体。格式:iv(12) ‖ AES-GCM(K_label, 名称) ‖ tag(16), * 加密设备名,作为席位名称 PUT 请求体。格式:iv(12) ‖ AES-GCM(K_label, 名称) ‖ tag(16),
* AAD = "WD1L" ‖ channel_id ‖ endpoint_hash。raw 模式下设备名是明文 UTF-8。 * AAD = "WD1L" ‖ channel_id ‖ endpoint_hash。raw 模式下设备名是明文 UTF-8。
* @param opts.endpointHash 指定目标席位;所有者可给其他终端重命名。
* @param opts.iv 仅用于测试向量,真实使用必须让库随机生成。 * @param opts.iv 仅用于测试向量,真实使用必须让库随机生成。
*/ */
async sealLabel(name: string, opts: { iv?: Uint8Array } = {}): Promise<Bytes> { async sealLabel(name: string, opts: { iv?: Uint8Array; endpointHash?: Uint8Array | string } = {}): Promise<Bytes> {
const endpointHash = typeof opts.endpointHash === 'string'
? fromBase64Url(opts.endpointHash, 32)
: opts.endpointHash ?? this.endpointHash;
if (!endpointHash || endpointHash.length !== 32) throw new RangeError('终端标识必须是 32 字节');
const plain = utf8(name); const plain = utf8(name);
if (this.mode === 'raw') { if (this.mode === 'raw') {
if (plain.length > MAX_LABEL_BYTES) throw new LimitError('设备名过长'); if (plain.length > MAX_LABEL_BYTES) throw new LimitError('设备名过长');
@@ -347,7 +352,7 @@ export class ChannelSession {
} }
const iv = opts.iv ?? randomBytes(LABEL_IV_SIZE); const iv = opts.iv ?? randomBytes(LABEL_IV_SIZE);
const sealed = await globalThis.crypto.subtle.encrypt( const sealed = await globalThis.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv as Bytes, additionalData: this.#labelAad(this.endpointHash), tagLength: 128 }, { name: 'AES-GCM', iv: iv as Bytes, additionalData: this.#labelAad(endpointHash), tagLength: 128 },
(this.#keys as ChannelKeys).labelKey, (this.#keys as ChannelKeys).labelKey,
plain, plain,
); );
+9 -8
View File
@@ -57,13 +57,15 @@ class ByteQueue {
async function* bytesOf(source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array, void, void> { async function* bytesOf(source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array, void, void> {
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) { if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
const reader = source.getReader(); const reader = source.getReader();
let finished = false;
try { try {
for (;;) { for (;;) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) return; if (done) { finished = true; return; }
yield value; yield value;
} }
} finally { } finally {
if (!finished) await reader.cancel().catch(() => {});
reader.releaseLock(); reader.releaseLock();
} }
} }
@@ -136,21 +138,19 @@ export async function* decryptItemFrames(
if (finalSeen) throw new IntegrityError('frame_after_final'); if (finalSeen) throw new IntegrityError('frame_after_final');
if (env.frameIndex !== expected) throw new IntegrityError('gap'); if (env.frameIndex !== expected) throw new IntegrityError('gap');
const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot); const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot | Flags.Control);
if (flagsMask === undefined) flagsMask = itemFlags; if (flagsMask === undefined) flagsMask = itemFlags;
else if (flagsMask !== itemFlags) throw new IntegrityError('flags_changed'); else if (flagsMask !== itemFlags) throw new IntegrityError('flags_changed');
let data: Bytes; let data: Bytes;
if (ctx.mode === 'enc') { if (ctx.mode === 'enc') {
data = await openFrame(await ctx.itemKey(env.itemId), ctx.channelId, env); data = await openFrame(await ctx.itemKey(env.itemId), ctx.channelId, env);
if (env.frameIndex === 0) {
const first = decodeFirstFrame(data);
meta = first.meta;
data = first.content;
}
} else { } else {
data = env.payload; data = env.payload;
} }
if (env.frameIndex === 0) {
const first = decodeFirstFrame(data); meta = first.meta; data = first.content;
}
expected++; expected++;
if ((env.flags & Flags.Final) !== 0) finalSeen = true; if ((env.flags & Flags.Final) !== 0) finalSeen = true;
@@ -190,7 +190,8 @@ export function decryptItemStream(
controller.error(e); controller.error(e);
} }
}, },
async cancel() { async cancel(reason) {
reject(reason ?? new DOMException('解密已取消', 'AbortError'));
await gen.return({ complete: false, nextIndex: opts.fromIndex ?? 0, meta: undefined }); await gen.return({ complete: false, nextIndex: opts.fromIndex ?? 0, meta: undefined });
}, },
}); });
+41 -24
View File
@@ -30,7 +30,9 @@ const exe = process.env.WEBDROP_SERVER ?? join(root, 'out', 'win-x64', 'webdrop.
const linuxExe = join(root, 'out', 'linux-x64', 'webdrop'); const linuxExe = join(root, 'out', 'linux-x64', 'webdrop');
const dll = process.env.WEBDROP_SERVER_DLL ?? join(root, 'src', 'WebDrop.Api', 'bin', 'Debug', 'net10.0', 'webdrop.dll'); const dll = process.env.WEBDROP_SERVER_DLL ?? join(root, 'src', 'WebDrop.Api', 'bin', 'Debug', 'net10.0', 'webdrop.dll');
const command: [string, string[]] | undefined = existsSync(exe) const command: [string, string[]] | undefined = !process.env.WEBDROP_SERVER && existsSync(dll)
? ['dotnet', [dll]]
: existsSync(exe)
? [exe, []] ? [exe, []]
: existsSync(linuxExe) : existsSync(linuxExe)
? [linuxExe, []] ? [linuxExe, []]
@@ -41,6 +43,10 @@ const command: [string, string[]] | undefined = existsSync(exe)
let proc: ChildProcess | undefined; let proc: ChildProcess | undefined;
let dataDir = ''; let dataDir = '';
let base = ''; let base = '';
let accountToken = '';
function headersFor(session: ChannelSession, role: AuthRole): Record<string, string> {
return { ...session.authHeaders(role), ...(session.role === 'owner' ? { 'X-WD-Account': accountToken } : {}) };
}
beforeAll(async () => { beforeAll(async () => {
if (!command) return; if (!command) return;
@@ -69,6 +75,16 @@ beforeAll(async () => {
}); });
proc!.on('exit', (code) => reject(new Error(`服务端提前退出,代码 ${code}`))); proc!.on('exit', (code) => reject(new Error(`服务端提前退出,代码 ${code}`)));
}); });
const verifier = crypto.getRandomValues(new Uint8Array(32));
const keys = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
const pubkey = await crypto.subtle.exportKey('jwk', keys.publicKey);
const encoded = (value: Uint8Array) => Buffer.from(value).toString('base64');
const registered = await fetch(`${base}/api/v1/accounts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
username: 'core_e2e', salt: encoded(crypto.getRandomValues(new Uint8Array(16))), verifier: encoded(verifier), vault: encoded(new Uint8Array(28)), public_key: JSON.stringify(pubkey),
}) });
expect(registered.status).toBe(204);
const session = await fetch(`${base}/api/v1/accounts/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'core_e2e', verifier: encoded(verifier) }) });
expect(session.status).toBe(200); accountToken = ((await session.json()) as { token: string }).token;
}, 30_000); }, 30_000);
afterAll(async () => { afterAll(async () => {
@@ -85,7 +101,7 @@ async function createChannel(opts: Parameters<typeof ChannelSession.create>[0] =
const created = await ChannelSession.create(opts); const created = await ChannelSession.create(opts);
const res = await fetch(api('/channels'), { const res = await fetch(api('/channels'), {
method: 'POST', method: 'POST',
headers: { ...created.request.headers, 'Content-Type': 'application/json' }, headers: { ...created.request.headers, 'X-WD-Account': accountToken, 'Content-Type': 'application/json' },
body: JSON.stringify(created.request.body), body: JSON.stringify(created.request.body),
}); });
expect(res.status).toBe(201); expect(res.status).toBe(201);
@@ -95,7 +111,7 @@ async function createChannel(opts: Parameters<typeof ChannelSession.create>[0] =
} }
async function joinChannel(session: ChannelSession, role: AuthRole = 'read') { async function joinChannel(session: ChannelSession, role: AuthRole = 'read') {
const res = await fetch(api(`/channels/${session.id}/seats`), { method: 'POST', headers: session.authHeaders(role) }); const res = await fetch(api(`/channels/${session.id}/seats`), { method: 'POST', headers: headersFor(session, role) });
expect(res.status, await res.clone().text()).toBe(200); expect(res.status, await res.clone().text()).toBe(200);
} }
@@ -105,7 +121,7 @@ async function send(session: ChannelSession, plan: ItemPlan, role: AuthRole) {
for await (const frame of plan.frames) { for await (const frame of plan.frames) {
const res = await fetch(api(`/channels/${session.id}/frames`), { const res = await fetch(api(`/channels/${session.id}/frames`), {
method: 'POST', method: 'POST',
headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...(first ? plan.frame0Headers : {}) }, headers: { ...headersFor(session, role), 'Content-Type': 'application/octet-stream', ...(first ? plan.frame0Headers : {}) },
body: frame.bytes, body: frame.bytes,
}); });
expect(res.status, await res.clone().text()).toBeLessThan(300); expect(res.status, await res.clone().text()).toBeLessThan(300);
@@ -116,16 +132,17 @@ async function send(session: ChannelSession, plan: ItemPlan, role: AuthRole) {
} }
async function readLog(session: ChannelSession, role: AuthRole, from = 0) { async function readLog(session: ChannelSession, role: AuthRole, from = 0) {
const res = await fetch(api(`/channels/${session.id}/log?from=${from}`), { headers: session.authHeaders(role) }); const res = await fetch(api(`/channels/${session.id}/log?from=${from}`), { headers: headersFor(session, role) });
expect(res.status).toBe(200); expect(res.status).toBe(200);
return parseLogRecords(new Uint8Array(await res.arrayBuffer())); return parseLogRecords(new Uint8Array(await res.arrayBuffer()));
} }
/** 连接 WebSocket(凭据放在子协议里),收集事件直到满足 until。 */ /** 连接 WebSocket(凭据放在子协议里),收集事件直到满足 until。 */
function subscribe(session: ChannelSession, role: AuthRole, until: (events: ItemEvent[]) => boolean, from = 0) { function subscribe(session: ChannelSession, role: AuthRole, until: (events: ItemEvent[]) => boolean, from = 0) {
const h = session.authHeaders(role); const h = headersFor(session, role);
const protocols = ['wd.v1', `wd.role.${role}`, `wd.ep.${h['X-WD-Endpoint']!}`]; const protocols = ['wd.v1', `wd.role.${role}`, `wd.ep.${h['X-WD-Endpoint']!}`];
if (h.Authorization) protocols.push(`wd.auth.${h.Authorization.replace('Bearer ', '')}`); if (h.Authorization) protocols.push(`wd.auth.${h.Authorization.replace('Bearer ', '')}`);
if (session.role === 'owner') protocols.push(`wd.account.${accountToken.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`);
const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/channels/${session.id}/ws?from=${from}`, protocols); const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/channels/${session.id}/ws?from=${from}`, protocols);
ws.binaryType = 'arraybuffer'; ws.binaryType = 'arraybuffer';
@@ -227,7 +244,7 @@ suite('端到端(真实服务端)', () => {
const frame = (await collectFrames(again))[0]!; const frame = (await collectFrames(again))[0]!;
const res = await fetch(api(`/channels/${owner.id}/frames`), { const res = await fetch(api(`/channels/${owner.id}/frames`), {
method: 'POST', method: 'POST',
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' },
body: frame.bytes, body: frame.bytes,
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -239,7 +256,7 @@ suite('端到端(真实服务端)', () => {
const marker = 'UNIQUE-PLAINTEXT-MARKER-7f3a9c'; const marker = 'UNIQUE-PLAINTEXT-MARKER-7f3a9c';
await send(owner, owner.encryptText(marker), 'owner'); await send(owner, owner.encryptText(marker), 'owner');
const res = await fetch(api(`/channels/${owner.id}/log`), { headers: owner.authHeaders('owner') }); const res = await fetch(api(`/channels/${owner.id}/log`), { headers: headersFor(owner, 'owner') });
const wire = new Uint8Array(await res.arrayBuffer()); const wire = new Uint8Array(await res.arrayBuffer());
expect(new TextDecoder('latin1').decode(wire)).not.toContain(marker); expect(new TextDecoder('latin1').decode(wire)).not.toContain(marker);
expect(new TextDecoder('latin1').decode(wire)).not.toContain('"kind"'); expect(new TextDecoder('latin1').decode(wire)).not.toContain('"kind"');
@@ -260,7 +277,7 @@ suite('端到端(真实服务端)', () => {
expect(log.map((r) => r.kind)).toEqual(['announce']); expect(log.map((r) => r.kind)).toEqual(['announce']);
// 流式下载 + 流式解密 // 流式下载 + 流式解密
const res = await fetch(api(`/channels/${owner.id}/items/${plan.itemId}/frames`), { headers: member.authHeaders('read') }); const res = await fetch(api(`/channels/${owner.id}/items/${plan.itemId}/frames`), { headers: headersFor(member, 'read') });
expect(res.status).toBe(200); expect(res.status).toBe(200);
const { content: stream, result } = member.decryptItemStream(res.body!, { itemId: plan.idBytes }); const { content: stream, result } = member.decryptItemStream(res.body!, { itemId: plan.idBytes });
const out = await readAll(stream); const out = await readAll(stream);
@@ -278,15 +295,15 @@ suite('端到端(真实服务端)', () => {
await send(owner, plan, 'owner'); await send(owner, plan, 'owner');
const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`); const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`);
const first = await fetch(url, { headers: owner.authHeaders('owner') }); const first = await fetch(url, { headers: headersFor(owner, 'owner') });
const decoded = owner.decryptItemStream(first.body!); const decoded = owner.decryptItemStream(first.body!);
expect(Buffer.compare(await readAll(decoded.content), content)).toBe(0); expect(Buffer.compare(await readAll(decoded.content), content)).toBe(0);
const second = await fetch(url, { headers: owner.authHeaders('owner') }); const second = await fetch(url, { headers: headersFor(owner, 'owner') });
expect(second.status).toBe(410); expect(second.status).toBe(410);
expect(((await second.json()) as { error: string }).error).toBe('gone'); expect(((await second.json()) as { error: string }).error).toBe('gone');
const resumed = await fetch(`${url}?from=1`, { headers: owner.authHeaders('owner') }); const resumed = await fetch(`${url}?from=1`, { headers: headersFor(owner, 'owner') });
expect(resumed.status).toBe(200); expect(resumed.status).toBe(200);
await resumed.arrayBuffer(); await resumed.arrayBuffer();
}, 60_000); }, 60_000);
@@ -308,7 +325,7 @@ suite('端到端(真实服务端)', () => {
if (sent === 2) break; if (sent === 2) break;
const res = await fetch(api(`/channels/${owner.id}/frames`), { const res = await fetch(api(`/channels/${owner.id}/frames`), {
method: 'POST', method: 'POST',
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream', ...(isFirst ? first.frame0Headers : {}) }, headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream', ...(isFirst ? first.frame0Headers : {}) },
body: f.bytes, body: f.bytes,
}); });
expect(res.status).toBe(201); expect(res.status).toBe(201);
@@ -322,14 +339,14 @@ suite('端到端(真实服务端)', () => {
for await (const f of rest.frames) { for await (const f of rest.frames) {
const res = await fetch(api(`/channels/${owner.id}/frames`), { const res = await fetch(api(`/channels/${owner.id}/frames`), {
method: 'POST', method: 'POST',
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' },
body: f.bytes, body: f.bytes,
}); });
statuses.push(res.status); statuses.push(res.status);
} }
expect(statuses).toEqual([200, 201, 201]); // 第 1 帧已存在(幂等),第 2、3 帧是新的 expect(statuses).toEqual([200, 201, 201]); // 第 1 帧已存在(幂等),第 2、3 帧是新的
const res = await fetch(api(`/channels/${owner.id}/items/${toBase64Url(itemId)}/frames`), { headers: owner.authHeaders('owner') }); const res = await fetch(api(`/channels/${owner.id}/items/${toBase64Url(itemId)}/frames`), { headers: headersFor(owner, 'owner') });
const { content: stream, result } = owner.decryptItemStream(res.body!); const { content: stream, result } = owner.decryptItemStream(res.body!);
expect(Buffer.compare(await readAll(stream), content)).toBe(0); expect(Buffer.compare(await readAll(stream), content)).toBe(0);
expect((await result).complete).toBe(true); expect((await result).complete).toBe(true);
@@ -343,7 +360,7 @@ suite('端到端(真实服务端)', () => {
const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`); const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`);
// 第一次:只读前约 1.5 帧就断开 // 第一次:只读前约 1.5 帧就断开
const first = await fetch(url, { headers: owner.authHeaders('owner') }); const first = await fetch(url, { headers: headersFor(owner, 'owner') });
const reader = first.body!.getReader(); const reader = first.body!.getReader();
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
let got = 0; let got = 0;
@@ -361,7 +378,7 @@ suite('端到端(真实服务端)', () => {
})(); })();
expect(part1.result.complete).toBe(false); expect(part1.result.complete).toBe(false);
const second = await fetch(`${url}?from=${part1.result.nextIndex}`, { headers: owner.authHeaders('owner') }); const second = await fetch(`${url}?from=${part1.result.nextIndex}`, { headers: headersFor(owner, 'owner') });
expect(second.status).toBe(200); expect(second.status).toBe(200);
const dec2 = owner.decryptItemStream(second.body!, { fromIndex: part1.result.nextIndex }); const dec2 = owner.decryptItemStream(second.body!, { fromIndex: part1.result.nextIndex });
const part2 = await readAll(dec2.content); const part2 = await readAll(dec2.content);
@@ -378,12 +395,12 @@ suite('端到端(真实服务端)', () => {
const blob = await member.sealLabel('我的 iPhone'); const blob = await member.sealLabel('我的 iPhone');
const put = await fetch(api(`/channels/${owner.id}/seats/me`), { const put = await fetch(api(`/channels/${owner.id}/seats/me`), {
method: 'PUT', method: 'PUT',
headers: { ...member.authHeaders('read'), 'Content-Type': 'application/octet-stream' }, headers: { ...headersFor(member, 'read'), 'Content-Type': 'application/octet-stream' },
body: blob, body: blob,
}); });
expect(put.status).toBe(204); expect(put.status).toBe(204);
const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: owner.authHeaders('owner') }); const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: headersFor(owner, 'owner') });
const seats = (await res.json()) as Array<{ endpoint_hash: string; label?: string }>; const seats = (await res.json()) as Array<{ endpoint_hash: string; label?: string }>;
const mine = seats.find((s) => s.endpoint_hash === toBase64Url(member.endpointHash))!; const mine = seats.find((s) => s.endpoint_hash === toBase64Url(member.endpointHash))!;
expect(await owner.openLabel(mine.label!, mine.endpoint_hash)).toBe('我的 iPhone'); expect(await owner.openLabel(mine.label!, mine.endpoint_hash)).toBe('我的 iPhone');
@@ -396,7 +413,7 @@ suite('端到端(真实服务端)', () => {
const blob = await owner.sealLabel('x'.repeat(228)); const blob = await owner.sealLabel('x'.repeat(228));
const put = await fetch(api(`/channels/${owner.id}/seats/me`), { const put = await fetch(api(`/channels/${owner.id}/seats/me`), {
method: 'PUT', method: 'PUT',
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' },
body: blob, body: blob,
}); });
expect(put.status).toBe(204); expect(put.status).toBe(204);
@@ -415,11 +432,11 @@ suite('端到端(真实服务端)', () => {
for (const r of records) out.push(...(await reader.push(r))); for (const r of records) out.push(...(await reader.push(r)));
return out; return out;
})()); })());
expect(item!.kind).toBe('raw'); expect(item!.kind).toBe('text');
expect(itemText(item!)).toBe('raw 明文 visible'); expect(itemText(item!)).toBe('raw 明文 visible');
// 对比 enc:raw 的明文确实出现在服务端返回的字节里 // 对比 enc:raw 的明文确实出现在服务端返回的字节里
const res = await fetch(api(`/channels/${owner.id}/log`), { headers: member.authHeaders('read') }); const res = await fetch(api(`/channels/${owner.id}/log`), { headers: headersFor(member, 'read') });
expect(new TextDecoder().decode(await res.arrayBuffer())).toContain('raw 明文 visible'); expect(new TextDecoder().decode(await res.arrayBuffer())).toContain('raw 明文 visible');
}); });
@@ -428,8 +445,8 @@ suite('端到端(真实服务端)', () => {
const member = await ChannelSession.open(owner.link(ORIGIN)); const member = await ChannelSession.open(owner.link(ORIGIN));
await joinChannel(member, 'read'); await joinChannel(member, 'read');
const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: { ...member.authHeaders('read') } }); const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: { ...headersFor(member, 'read') } });
expect(res.status).toBe(403); expect(res.status).toBe(403);
expect(() => member.authHeaders('owner')).toThrow(); expect(() => headersFor(member, 'owner')).toThrow();
}); });
}); });
+12 -10
View File
@@ -315,29 +315,30 @@ describe('设备名', () => {
}); });
describe('raw 模式往返', () => { describe('raw 模式往返', () => {
it('单帧:负载就是内容,没有加密标志', async () => { it('单帧:保留统一元数据,没有加密标志', async () => {
const { session: owner } = await ChannelSession.create({ mode: 'raw' }); const { session: owner } = await ChannelSession.create({ mode: 'raw' });
const member = await ChannelSession.open(owner.link(ORIGIN)); const member = await ChannelSession.open(owner.link(ORIGIN));
const [frame] = await collectFrames(owner.encryptText('明文内容')); const [frame] = await collectFrames(owner.encryptText('明文内容'));
expect(frame!.bytes[1]! & 8).toBe(0); // 没有 ENC 标志 expect(frame!.bytes[1]! & 8).toBe(0); // 没有 ENC 标志
expect(new TextDecoder().decode(frame!.bytes.slice(26))).toBe('明文内容'); expect(new TextDecoder().decode(frame!.bytes.slice(26))).toContain('明文内容');
const item = itemOf(await member.createItemReader().pushFrame(frame!.bytes)); const item = itemOf(await member.createItemReader().pushFrame(frame!.bytes));
expect(item.kind).toBe('raw'); expect(item.kind).toBe('text');
expect(item.meta).toBeUndefined(); expect(item.meta?.kind).toBe('text');
expect(itemText(item)).toBe('明文内容'); expect(itemText(item)).toBe('明文内容');
}); });
it('大内容拆成 GATED 多帧', async () => { it('大内容拆成 GATED 多帧', async () => {
const { session } = await ChannelSession.create({ mode: 'raw' }); const { session } = await ChannelSession.create({ mode: 'raw' });
const content = pseudoRandom(100, 5); const content = pseudoRandom(100, 5);
const plan = session.encryptBytes(content, {}, { maxFrameData: 40 }); const plan = session.encryptBytes(content, {}, { maxFrameData: 80 });
const frames = await collectFrames(plan); const frames = await collectFrames(plan);
expect(plan.gated).toBe(true); expect(plan.gated).toBe(true);
expect(frames.map((f) => f.bytes.length - 26)).toEqual([40, 40, 20]); expect(frames.length).toBeGreaterThan(1);
expect(frames.map((f) => f.final)).toEqual([false, false, true]); expect(frames.every((f) => f.bytes.length - 26 <= 80)).toBe(true);
expect(frames.at(-1)?.final).toBe(true);
const reader = session.createItemReader(); const reader = session.createItemReader();
let last: ReceivedItem | undefined; let last: ReceivedItem | undefined;
@@ -346,10 +347,11 @@ describe('raw 模式往返', () => {
expect(toHex(last!.content)).toBe(toHex(content)); expect(toHex(last!.content)).toBe(toHex(content));
}); });
it('label 放进第 0 帧的附加请求头', async () => { it('名称使用与加密条目一致的元数据', async () => {
const { session } = await ChannelSession.create({ mode: 'raw' }); const { session } = await ChannelSession.create({ mode: 'raw' });
const plan = session.encryptBytes(new Uint8Array(3), {}, { label: 'note.txt' }); const plan = session.encryptBytes(new Uint8Array(3), { name: 'note.txt' });
expect(plan.frame0Headers['X-WD-Label']).toBe('note.txt'); const [frame] = await collectFrames(plan);
expect(itemOf(await session.createItemReader().pushFrame(frame!.bytes)).meta?.name).toBe('note.txt');
}); });
}); });
+3
View File
@@ -149,7 +149,10 @@ describe('条目分帧', () => {
itemId: h(item.item_id), itemId: h(item.item_id),
ts: m.ts, ts: m.ts,
...(m.dev !== undefined && { dev: m.dev }), ...(m.dev !== undefined && { dev: m.dev }),
...(m.thread_id !== undefined && { threadId: m.thread_id }),
...(m.folder_id !== undefined && { folderId: m.folder_id }),
snapshot: item.snapshot, snapshot: item.snapshot,
control: item.control,
gated: item.force_gated ? true : 'auto', gated: item.force_gated ? true : 'auto',
maxFrameData: item.max_frame_data, maxFrameData: item.max_frame_data,
}, },
+145
View File
@@ -0,0 +1,145 @@
// Run with the Playwright browser_run_code tool's filename option against an isolated development API.
async (page) => {
const origin = new URL(page.url()).origin;
const password = 'Architecture-test-password-2026';
const suffix = Date.now().toString();
const ownerName = `smoke_owner_${suffix}`;
const memberName = `smoke_member_${suffix}`;
const browser = page.context().browser();
const contexts = [];
const results = [];
async function login(target, username, create = false, path = '/') {
await target.goto(origin + path);
if (!create) await target.getByRole('button', { name: '已有账号', exact: true }).click();
await target.getByLabel('用户名', { exact: true }).fill(username);
await target.getByLabel('密码', { exact: true }).fill(password);
await target.getByRole('button', { name: create ? '创建账号' : '登录并同步', exact: true }).click();
await target.getByRole('button', { name: '新建空间', exact: true }).waitFor();
}
async function newDevice() {
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
contexts.push(context); return context.newPage();
}
try {
const owner = await newDevice();
await login(owner, ownerName, true);
await owner.getByLabel('个人名称', { exact: true }).fill('多设备测试用户');
const avatar = await owner.evaluate(() => {
const canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 32;
const context = canvas.getContext('2d'); context.fillRect(0, 0, 32, 32); return canvas.toDataURL('image/png').split(',')[1];
});
await owner.getByLabel('头像', { exact: true }).setInputFiles({ name: 'avatar.png', mimeType: 'image/png', buffer: Buffer.from(avatar, 'base64') });
await owner.getByRole('img', { name: '个人头像', exact: true }).waitFor();
await owner.getByRole('button', { name: '保存个人资料', exact: true }).click();
await owner.getByText('个人资料已保存并同步。', { exact: true }).waitFor();
await owner.reload();
await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor();
if (await owner.getByLabel('个人名称', { exact: true }).inputValue() !== '多设备测试用户') throw new Error('Profile did not survive reload');
await owner.getByRole('img', { name: '个人头像', exact: true }).waitFor();
results.push('login, encrypted profile and avatar survive reload');
await owner.getByRole('button', { name: '新建空间', exact: true }).click();
await owner.getByLabel('空间名称(加入者和服务端可见)').fill('架构验收空间');
await owner.getByRole('button', { name: '创建', exact: true }).click();
await owner.getByRole('button', { name: '常规', exact: true }).waitFor();
await owner.getByRole('button', { name: '新建频道分组', exact: true }).click();
await owner.getByLabel('分组名称', { exact: true }).fill('工作');
await owner.getByRole('button', { name: '创建', exact: true }).click();
await owner.getByRole('button', { name: '在 工作 新建频道', exact: true }).click();
await owner.getByLabel('频道名称', { exact: true }).fill('讨论');
await owner.getByRole('button', { name: '创建', exact: true }).click();
await owner.getByRole('button', { name: '讨论', exact: true }).waitFor();
await owner.getByRole('button', { name: '常规', exact: true }).click();
await owner.getByRole('button', { name: '频道设置 常规', exact: true }).click();
await owner.getByLabel('所属分组').selectOption({ label: '工作' });
await owner.getByRole('button', { name: '保存', exact: true }).click();
await owner.getByRole('button', { name: '重命名分组 工作', exact: true }).click();
await owner.getByLabel('分组名称', { exact: true }).fill('项目');
await owner.getByRole('button', { name: '保存', exact: true }).click();
await owner.getByRole('button', { name: '分组 项目', exact: true }).click();
if (await owner.getByRole('button', { name: '讨论', exact: true }).isVisible()) throw new Error('Group did not collapse');
await owner.getByRole('button', { name: '分组 项目', exact: true }).click();
await owner.reload();
await owner.getByRole('region', { name: '频道分组 项目', exact: true }).getByRole('button', { name: '常规', exact: true }).waitFor();
await owner.getByRole('region', { name: '频道分组 项目', exact: true }).getByRole('button', { name: '讨论', exact: true }).waitFor();
await owner.getByRole('button', { name: '讨论', exact: true }).click();
await owner.getByRole('button', { name: '频道设置 讨论', exact: true }).click();
await owner.getByLabel('所属分组').selectOption({ label: '未分组' });
await owner.getByRole('button', { name: '保存', exact: true }).click();
if (await owner.getByRole('region', { name: '频道分组 项目', exact: true }).getByRole('button', { name: '讨论', exact: true }).count()) throw new Error('Ungrouping did not remove group membership');
await owner.getByRole('button', { name: '频道设置 讨论', exact: true }).click();
await owner.getByLabel('所属分组').selectOption({ label: '项目' });
await owner.getByRole('button', { name: '保存', exact: true }).click();
await owner.getByRole('button', { name: '常规', exact: true }).click();
results.push('channel groups create, rename, collapse and survive encrypted replay');
const path = new URL(owner.url()).pathname;
if (owner.url().includes('#')) throw new Error('Account channel navigation retained a secret fragment');
await owner.locator('textarea').last().fill('常规 thread 聊天验证');
await owner.getByRole('button', { name: '发送消息', exact: true }).click();
await owner.locator('article p').filter({ hasText: '常规 thread 聊天验证' }).waitFor();
const payload = '端到端文件验证:只有成员设备可解密。'.repeat(60000);
await owner.getByRole('region', { name: '频道文件管理器' }).locator('input[type=file]').setInputFiles({ name: 'architecture-file.txt', mimeType: 'text/plain', buffer: Buffer.from(payload) });
await owner.getByRole('button', { name: '继续上传', exact: true }).click();
await owner.getByRole('button', { name: 'architecture-file.txt', exact: true }).waitFor();
results.push('default thread, center chat and right file explorer work');
const second = await newDevice();
await login(second, ownerName, false, path);
await second.getByRole('button', { name: 'architecture-file.txt', exact: true }).waitFor();
await second.locator('article p').filter({ hasText: '常规 thread 聊天验证' }).waitFor();
await second.getByRole('region', { name: '频道分组 项目', exact: true }).getByRole('button', { name: '讨论', exact: true }).waitFor();
results.push('second independent device recovers owner channel without a secret link');
const member = await newDevice();
await login(member, memberName, true);
await owner.getByRole('button', { name: '空间设置', exact: true }).click();
await owner.getByLabel('被邀请用户的用户名').fill(memberName);
await owner.getByRole('button', { name: '发送邀请', exact: true }).click();
await owner.getByText(`${memberName} · 已邀请`, { exact: true }).waitFor();
await owner.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
let downloads = 0;
member.on('request', (request) => { if (/\/items\/[^/]+\/frames/.test(request.url())) downloads++; });
await member.goto(origin + path);
await member.getByRole('button', { name: '接受并加入空间', exact: true }).click();
await member.getByRole('button', { name: 'architecture-file.txt', exact: true }).waitFor();
if (downloads !== 0) throw new Error('File catalog consumed a gated download');
await member.getByRole('button', { name: 'architecture-file.txt', exact: true }).dblclick();
await member.getByRole('button', { name: '解密并预览文件', exact: true }).click();
await member.locator('pre').filter({ hasText: '端到端文件验证' }).waitFor();
if (downloads !== 1) throw new Error(`Expected one explicit gated read, got ${downloads}`);
await member.reload();
await member.getByRole('button', { name: 'architecture-file.txt', exact: true }).waitFor();
if (downloads !== 1) throw new Error('Refreshing consumed another download');
results.push('account invitation automatically unlocks ciphertext and catalog never auto-downloads');
await owner.getByRole('button', { name: '新建文件夹', exact: true }).click();
await owner.getByLabel('文件夹名称', { exact: true }).fill('资料目录');
await owner.getByRole('button', { name: '创建', exact: true }).click();
await owner.getByRole('button', { name: '资料目录', exact: true }).waitFor();
await owner.getByRole('checkbox', { name: '选择文件 architecture-file.txt', exact: true }).check();
await owner.getByLabel('移动所选文件到文件夹').selectOption({ label: '资料目录' });
await owner.getByRole('button', { name: '资料目录', exact: true }).dblclick();
await owner.getByRole('button', { name: 'architecture-file.txt', exact: true }).waitFor();
results.push('folder navigation and encrypted file moves work');
await second.goto(origin);
const selfRow = second.getByRole('region', { name: '账号设备' }).locator('div').filter({ has: second.getByText('本机', { exact: true }) });
await selfRow.locator('input').fill('验收副设备');
await selfRow.getByRole('button', { name: '重命名', exact: true }).click();
await second.getByLabel('验收副设备 的设备名称', { exact: true }).waitFor();
await owner.goto(origin);
const otherRow = owner.getByRole('region', { name: '账号设备' }).locator('div').filter({ has: owner.getByLabel('验收副设备 的设备名称', { exact: true }) });
await owner.evaluate(() => { window.confirm = () => true; });
await otherRow.getByRole('button', { name: '移除设备', exact: true }).click();
await second.reload();
await second.getByRole('button', { name: '创建账号', exact: true }).waitFor();
await owner.reload();
await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor();
results.push('revoked device loses remembered login while the other device stays logged in');
await member.goto(origin + path);
for (const width of [1440, 390]) {
await member.setViewportSize({ width, height: 900 });
for (const colorScheme of ['light', 'dark']) {
await member.emulateMedia({ colorScheme });
if (await member.evaluate(() => document.documentElement.scrollWidth > innerWidth)) throw new Error(`Horizontal overflow at ${width}/${colorScheme}`);
}
}
results.push('desktop/mobile and light/dark layouts fit viewport');
return { passed: results };
} finally { for (const context of contexts) await context.close(); }
}
+55
View File
@@ -0,0 +1,55 @@
async (page) => {
const origin = new URL(page.url()).origin; const browser = page.context().browser();
const contexts = [await browser.newContext({ viewport: { width: 1440, height: 900 } }), await browser.newContext({ viewport: { width: 1440, height: 900 } })];
for (const context of contexts) context.setDefaultTimeout(10000);
const sender = await contexts[0].newPage(); const viewer = await contexts[1].newPage();
const stamp = Date.now(); const alice = `uploader_${stamp}`; const bob = `reader_${stamp}`;
async function register(target, username, name) {
await target.goto(origin); await target.getByLabel('用户名', { exact: true }).fill(username); await target.getByLabel('密码', { exact: true }).fill('Chat-files-smoke-password-2026');
await target.getByRole('button', { name: '创建账号', exact: true }).click(); await target.getByRole('button', { name: '新建空间', exact: true }).waitFor();
await target.getByLabel('个人名称', { exact: true }).fill(name);
const avatar = await target.evaluate(() => { const canvas = document.createElement('canvas'); canvas.width = 256; canvas.height = 256;
const ctx = canvas.getContext('2d'); ctx.fillStyle = '#447788'; ctx.fillRect(0, 0, 256, 256); return canvas.toDataURL('image/png').split(',')[1]; });
await target.getByLabel('头像', { exact: true }).setInputFiles({ name: 'profile.png', mimeType: 'image/png', buffer: Buffer.from(avatar, 'base64') });
await target.getByRole('img', { name: '个人头像', exact: true }).waitFor(); await target.getByRole('button', { name: '保存个人资料', exact: true }).click();
await target.getByText('个人资料已保存并同步。', { exact: true }).waitFor();
}
const results = [];
try {
await register(sender, alice, '小白'); await register(viewer, bob, '小黑');
await sender.getByRole('button', { name: '新建空间', exact: true }).click(); await sender.getByLabel('空间名称(加入者和服务端可见)').fill('文件聊天验收');
await sender.getByRole('button', { name: '创建', exact: true }).click(); await sender.getByRole('button', { name: '常规', exact: true }).waitFor();
const path = new URL(sender.url()).pathname;
await sender.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill('你好,这是一份项目文件。');
await sender.getByRole('button', { name: '发送消息', exact: true }).click();
await sender.getByRole('region', { name: '频道文件管理器' }).locator('input[type=file]').setInputFiles({ name: 'project-notes.txt', mimeType: 'text/plain', buffer: Buffer.from('project-content\n'.repeat(140000)) });
await sender.getByRole('button', { name: '继续上传', exact: true }).click(); await sender.getByRole('article', { name: '小白 上传了 project-notes.txt', exact: true }).waitFor();
await sender.getByRole('button', { name: '空间设置', exact: true }).click(); await sender.getByLabel('被邀请用户的用户名').fill(bob);
await sender.getByRole('button', { name: '发送邀请', exact: true }).click(); await sender.getByText(`${bob} · 已邀请`, { exact: true }).waitFor();
await sender.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
let downloads = 0; viewer.on('request', (r) => { if (/\/items\/[^/]+\/frames/.test(r.url())) downloads++; });
await viewer.goto(origin + path); await viewer.getByRole('button', { name: '接受并加入空间', exact: true }).click();
const file = viewer.getByRole('article', { name: '小白 上传了 project-notes.txt', exact: true }); await file.waitFor();
await file.getByRole('img', { name: '小白 的头像', exact: true }).waitFor();
const avatar = await file.getByRole('img').evaluate((img) => ({ width: img.naturalWidth, source: img.src.length }));
if (avatar.width !== 64 || avatar.source > 8000) throw new Error('Avatar was not safely resized');
await viewer.getByRole('article', { name: '小白 的消息', exact: true }).getByText('你好,这是一份项目文件。', { exact: true }).waitFor();
if (downloads !== 0) throw new Error('Displaying a file message consumed a download');
results.push('file and text bubbles show the uploader name and bounded avatar on another account');
await file.getByRole('button', { name: '预览', exact: true }).click(); await viewer.getByRole('button', { name: '解密并预览文件', exact: true }).click();
await viewer.getByRole('dialog').locator('pre').filter({ hasText: 'project-content' }).waitFor(); await viewer.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
if (downloads !== 1) throw new Error('Explicit file preview did not use exactly one download');
await file.getByRole('img', { name: '小白 的头像', exact: true }).waitFor();
await viewer.reload(); await viewer.getByRole('article', { name: '小白 上传了 project-notes.txt', exact: true }).getByRole('img', { name: '小白 的头像', exact: true }).waitFor();
if (downloads !== 1) throw new Error('Reloading file bubbles consumed a download');
results.push('identity survives download updates and encrypted history replay without automatic content reads');
await viewer.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill('我收到了。'); await viewer.getByRole('button', { name: '发送消息', exact: true }).click();
await sender.getByRole('article', { name: '小黑 的消息', exact: true }).getByText('我收到了。', { exact: true }).waitFor();
results.push('each participant keeps their own sender identity');
await viewer.screenshot({ path: 'C:/Users/JustMe/Desktop/webdrop-proj/.playwright-mcp/chat-file-messages.png', fullPage: true });
for (const width of [1440, 390]) { await viewer.setViewportSize({ width, height: 900 }); for (const colorScheme of ['light', 'dark']) {
await viewer.emulateMedia({ colorScheme }); if (await viewer.evaluate(() => document.documentElement.scrollWidth > innerWidth)) throw new Error('Chat layout overflow');
} }
return { passed: results };
} finally { for (const context of contexts) await context.close(); }
}
+99
View File
@@ -0,0 +1,99 @@
async (page) => {
const origin = new URL(page.url()).origin;
const browser = page.context().browser();
const firstContext = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const secondContext = await browser.newContext({ viewport: { width: 1440, height: 900 } });
firstContext.setDefaultTimeout(10000); secondContext.setDefaultTimeout(10000);
const owner = await firstContext.newPage(); const device = await secondContext.newPage();
const username = `explorer_${Date.now()}`; const password = 'Explorer-smoke-password-2026';
const results = [];
try {
await owner.goto(origin); await owner.getByLabel('用户名', { exact: true }).fill(username); await owner.getByLabel('密码', { exact: true }).fill(password);
await owner.getByRole('button', { name: '创建账号', exact: true }).click(); await owner.getByRole('button', { name: '新建空间', exact: true }).click();
await owner.getByLabel('空间名称(加入者和服务端可见)').fill('文件交互验收'); await owner.getByRole('button', { name: '创建', exact: true }).click();
await owner.getByRole('button', { name: '常规', exact: true }).waitFor();
const path = new URL(owner.url()).pathname;
const png = await owner.evaluate(() => { const c = document.createElement('canvas'); c.width = 32; c.height = 32; c.getContext('2d').fillRect(0, 0, 32, 32); return c.toDataURL('image/png').split(',')[1]; });
const explorer = owner.getByRole('region', { name: '频道文件管理器' });
await explorer.locator('input[type=file]').setInputFiles([
{ name: 'a-note.txt', mimeType: 'text/plain', buffer: Buffer.from('hover-alpha') },
{ name: 'b-note.txt', mimeType: 'text/plain', buffer: Buffer.from('hover-beta') },
{ name: 'c-image.png', mimeType: 'image/png', buffer: Buffer.from(png, 'base64') },
{ name: 'z-locked.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(2 * 1024 * 1024, 7) },
]);
await owner.getByRole('button', { name: '继续上传', exact: true }).click(); await owner.getByRole('button', { name: 'z-locked.bin', exact: true }).waitFor();
await owner.getByRole('button', { name: '新建文件夹', exact: true }).click(); await owner.getByLabel('文件夹名称', { exact: true }).fill('资料');
await owner.getByRole('button', { name: '创建', exact: true }).click(); await owner.getByRole('button', { name: '资料', exact: true }).waitFor();
await device.goto(origin + path); await device.getByRole('button', { name: '已有账号', exact: true }).click();
await device.getByLabel('用户名', { exact: true }).fill(username); await device.getByLabel('密码', { exact: true }).fill(password);
await device.getByRole('button', { name: '登录并同步', exact: true }).click(); await device.getByRole('button', { name: 'z-locked.bin', exact: true }).waitFor();
let reads = 0; device.on('request', (r) => { if (/\/items\/[^/]+\/frames/.test(r.url())) reads++; });
await device.getByRole('button', { name: 'z-locked.bin', exact: true }).hover(); await device.getByRole('tooltip', { name: '悬浮文件预览' }).waitFor();
if (reads) throw new Error('Hover downloaded a gated file');
await device.getByRole('button', { name: 'a-note.txt', exact: true }).hover();
await device.getByRole('tooltip').locator('pre').filter({ hasText: 'hover-alpha' }).waitFor();
await device.getByRole('button', { name: 'c-image.png', exact: true }).hover(); await device.getByRole('img', { name: '文件缩略预览' }).waitFor();
if (reads) throw new Error('Hover unexpectedly fetched file frames'); results.push('hover previews synced text/images and never reads gated content');
const area = device.getByRole('region', { name: '频道文件管理器' });
await device.getByRole('button', { name: 'a-note.txt', exact: true }).click();
await device.getByRole('button', { name: 'b-note.txt', exact: true }).click({ modifiers: ['Control'] });
if (await area.locator('input[type=checkbox]:checked').count() !== 2) throw new Error('Ctrl multi-select failed');
await device.getByRole('button', { name: 'z-locked.bin', exact: true }).click({ modifiers: ['Shift'] });
if (await area.locator('input[type=checkbox]:checked').count() !== 3) throw new Error('Shift range selection failed');
await device.keyboard.press('Control+a'); if (await area.locator('input[type=checkbox]:checked').count() !== 4) throw new Error('Select all failed');
await device.keyboard.press('Escape'); if (await area.locator('input[type=checkbox]:checked').count()) throw new Error('Escape did not clear selection');
results.push('left click, Ctrl/Shift ranges and keyboard selection work');
await device.getByRole('button', { name: 'a-note.txt', exact: true }).click({ button: 'right' });
await device.getByRole('menu', { name: '文件操作菜单' }).waitFor(); await device.keyboard.press('ArrowDown'); await device.keyboard.press('Escape');
await device.getByRole('button', { name: '文件操作 a-note.txt', exact: true }).click(); await device.getByRole('menuitem', { name: '预览', exact: true }).click();
await device.getByRole('dialog').locator('pre').filter({ hasText: 'hover-alpha' }).waitFor(); await device.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
results.push('right click, accessible more-menu and explicit preview work');
const row = (name) => device.getByRole('button', { name, exact: true }).locator('xpath=ancestor::tr');
async function drag(source, target) {
await source.locator('input[type=checkbox]').check();
const start = await source.boundingBox();
await device.mouse.move(start.x + 80, start.y + start.height / 2); await device.mouse.down();
await device.mouse.move(start.x + 95, start.y + start.height / 2, { steps: 4 });
const end = await target.boundingBox();
await device.mouse.move(end.x + end.width / 2, end.y + end.height / 2, { steps: 12 });
await device.mouse.move(end.x + end.width / 2 + 1, end.y + end.height / 2, { steps: 2 }); await device.mouse.up();
}
await row('a-note.txt').dragTo(row('资料'));
await device.getByRole('button', { name: 'a-note.txt', exact: true }).waitFor({ state: 'hidden' });
await device.getByRole('button', { name: '资料', exact: true }).dblclick(); await device.getByRole('button', { name: 'a-note.txt', exact: true }).waitFor();
await drag(row('a-note.txt'), area.locator('button[data-drop-folder="root"]'));
await device.getByRole('button', { name: 'a-note.txt', exact: true }).waitFor({ state: 'hidden', timeout: 10000 }); await area.getByRole('button', { name: '根目录', exact: true }).click();
await device.getByRole('button', { name: 'a-note.txt', exact: true }).waitFor();
results.push('drag moves files into folders and breadcrumb drops return to root');
const transfer = await device.evaluateHandle(() => { const value = new DataTransfer(); value.items.add(new File(['external drop'], 'external-note.txt', { type: 'text/plain' })); return value; });
await row('资料').dispatchEvent('dragover', { dataTransfer: transfer }); await row('资料').dispatchEvent('drop', { dataTransfer: transfer });
await device.getByRole('button', { name: '继续上传', exact: true }).click(); await device.getByRole('button', { name: '资料', exact: true }).dblclick();
await device.getByRole('button', { name: 'external-note.txt', exact: true }).waitFor(); await area.getByRole('button', { name: '根目录', exact: true }).click(); await transfer.dispose();
results.push('external file drops upload directly into the target folder');
await device.getByRole('button', { name: 'a-note.txt', exact: true }).click(); await device.getByRole('button', { name: 'b-note.txt', exact: true }).click({ modifiers: ['Control'] });
await row('a-note.txt').dragTo(row('资料')); await device.getByRole('button', { name: 'b-note.txt', exact: true }).waitFor({ state: 'hidden' });
await device.getByRole('button', { name: '资料', exact: true }).dblclick(); await device.getByRole('button', { name: 'b-note.txt', exact: true }).waitFor();
await device.getByRole('button', { name: 'a-note.txt', exact: true }).click(); await device.getByRole('button', { name: 'b-note.txt', exact: true }).click({ modifiers: ['Control'] });
await device.getByRole('button', { name: 'a-note.txt', exact: true }).click({ button: 'right' }); await device.getByRole('menuitem', { name: '移动到文件夹…', exact: true }).click();
await device.getByRole('button', { name: '移动', exact: true }).click(); await area.getByRole('button', { name: '根目录', exact: true }).click();
await device.getByRole('button', { name: 'a-note.txt', exact: true }).waitFor(); await device.getByRole('button', { name: 'b-note.txt', exact: true }).waitFor();
if (reads) throw new Error('Moving files read file contents'); results.push('batch drag and move dialog preserve encrypted content without downloading');
const handle = device.getByRole('separator', { name: '调整文件区宽度', exact: true }); const box = await handle.boundingBox();
const before = (await area.boundingBox()).width; await device.mouse.move(box.x + 3, box.y + 50); await device.mouse.down(); await device.mouse.move(box.x - 80, box.y + 50); await device.mouse.up();
const wider = (await area.boundingBox()).width; if (wider < before + 60) throw new Error('File panel did not resize');
await handle.focus(); await device.keyboard.press('ArrowRight'); if ((await area.boundingBox()).width >= wider) throw new Error('Keyboard resize failed');
results.push('pointer and keyboard resize the file pane');
await device.evaluate(() => { window.confirm = () => true; });
await device.getByRole('button', { name: 'a-note.txt', exact: true }).click(); await device.getByRole('button', { name: '文件操作 a-note.txt', exact: true }).click();
await device.getByRole('menuitem', { name: '删除', exact: true }).click(); await device.getByRole('button', { name: 'a-note.txt', exact: true }).waitFor({ state: 'hidden' });
await device.reload(); await device.getByRole('button', { name: 'b-note.txt', exact: true }).waitFor();
if (await device.getByRole('button', { name: 'a-note.txt', exact: true }).count()) throw new Error('Deleted file returned after replay');
results.push('context deletion persists after reload');
for (const width of [1440, 390]) {
await device.setViewportSize({ width, height: 900 });
if (width < 900) await device.getByRole('button', { name: '文件', exact: true }).click();
for (const colorScheme of ['light', 'dark']) { await device.emulateMedia({ colorScheme }); if (await device.evaluate(() => document.documentElement.scrollWidth > innerWidth)) throw new Error('Explorer overflow'); }
}
return { passed: results };
} finally { await firstContext.close(); await secondContext.close(); }
}
+38
View File
@@ -0,0 +1,38 @@
async (page) => {
const origin = new URL(page.url()).origin; const browser = page.context().browser(); const context = await browser.newContext(); context.setDefaultTimeout(10000);
let target = await context.newPage(); const username = `remember_${Date.now()}`; const password = 'Remember-login-smoke-password'; const results = [];
async function stored() {
return target.evaluate(async () => {
const db = await new Promise((resolve, reject) => { const req = indexedDB.open('webdrop-account-local-2'); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); });
const tx = db.transaction(['sessions', 'credentials'], 'readonly');
const read = (name) => new Promise((resolve, reject) => { const req = tx.objectStore(name).getAll(); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); });
const [sessions, credentials] = await Promise.all([read('sessions'), read('credentials')]); db.close();
return { sessions: sessions.length, credentials: credentials.length, plaintext: credentials.some((value) => 'password' in value || 'username' in value), exportable: credentials.some((value) => value.key.extractable) };
});
}
async function reopen() { await target.close(); target = await context.newPage(); await target.goto(origin); }
try {
await target.goto(origin); await target.getByLabel('用户名', { exact: true }).fill(username); await target.getByLabel('密码', { exact: true }).fill(password);
await target.getByRole('button', { name: '创建账号', exact: true }).click(); await target.getByRole('button', { name: '退出登录', exact: true }).waitFor();
const record = await stored(); if (record.credentials !== 1 || record.plaintext || record.exportable) throw new Error('Login was not stored as non-exportable encrypted data');
await reopen(); await target.getByRole('button', { name: '退出登录', exact: true }).waitFor(); results.push('fresh page restores the remembered account');
await target.getByRole('button', { name: '退出登录', exact: true }).click(); await target.getByRole('button', { name: '登录并同步', exact: true }).waitFor();
if (await target.getByLabel('用户名', { exact: true }).inputValue() !== username || await target.getByLabel('密码', { exact: true }).inputValue() !== password) throw new Error('Saved account/password were not filled');
results.push('normal logout preserves encrypted credentials and fills the login form');
await target.getByRole('checkbox', { name: '记住账号和密码(本机)', exact: true }).uncheck(); await target.getByRole('button', { name: '登录并同步', exact: true }).click();
await target.getByRole('button', { name: '退出登录', exact: true }).waitFor(); await target.getByRole('button', { name: '立即同步', exact: true }).click();
await target.getByText('资源保险库已同步。', { exact: true }).waitFor();
const temporary = await stored(); if (temporary.sessions || temporary.credentials) throw new Error('Unremembered login was persisted by sync');
await reopen(); await target.getByLabel('用户名', { exact: true }).waitFor();
if (await target.getByLabel('用户名', { exact: true }).inputValue() || await target.getByLabel('密码', { exact: true }).inputValue()) throw new Error('Temporary login reappeared');
results.push('unchecked remember only keeps the current page session');
await target.getByRole('button', { name: '已有账号', exact: true }).click(); await target.getByLabel('用户名', { exact: true }).fill(username); await target.getByLabel('密码', { exact: true }).fill(password);
await target.getByRole('button', { name: '登录并同步', exact: true }).click(); await target.getByRole('button', { name: '清除本机保存', exact: true }).click();
await target.getByRole('button', { name: '立即同步', exact: true }).click(); await target.getByText('资源保险库已同步。', { exact: true }).waitFor();
const cleared = await stored(); if (cleared.sessions || cleared.credentials) throw new Error('Cleared login was restored by sync');
await reopen(); await target.getByLabel('密码', { exact: true }).waitFor();
if (await target.getByLabel('密码', { exact: true }).inputValue()) throw new Error('Password returned after clearing');
results.push('clearing local saves survives synchronization and page restart');
return { passed: results };
} finally { await context.close(); }
}
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
npm_cache="${NPM_CONFIG_CACHE:-$HOME/.cache/woodpecker/webdrop/npm}"
mkdir -p "$npm_cache"
cd "$repo_root/packages/core"
npm ci --cache "$npm_cache" --prefer-offline --no-audit
npm run typecheck
npm test
npm run vectors:check
npm run build
cd "$repo_root/web"
npm ci --cache "$npm_cache" --prefer-offline --no-audit
npm run typecheck
npm run lint:css
npm test
npm run build
+39
View File
@@ -0,0 +1,39 @@
$ErrorActionPreference = 'Stop'
function ConvertTo-WslPath([string]$Path) {
$fullPath = [IO.Path]::GetFullPath($Path)
if ($fullPath -notmatch '^([A-Za-z]):\\(.*)$') { throw "Workspace must be on a Windows drive: $fullPath" }
$drive = $Matches[1].ToLowerInvariant()
$relative = $Matches[2].Replace('\', '/')
return "/mnt/$drive/$relative"
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$repoWsl = ConvertTo-WslPath $repoRoot
$unitPath = (Resolve-Path (Join-Path $PSScriptRoot '..\deploy\webdrop.container')).Path
$unitWsl = ConvertTo-WslPath $unitPath
$updatePath = (Resolve-Path (Join-Path $PSScriptRoot 'wsl-podman-update.sh')).Path
$updateWsl = ConvertTo-WslPath $updatePath
$commit = (& git -C $repoRoot rev-parse --short=12 HEAD).Trim()
if ($LASTEXITCODE -ne 0) { throw 'Could not read the current Git commit.' }
$tagOutput = & git -C $repoRoot describe --tags --exact-match HEAD 2>$null
$tag = if ($tagOutput) { "$tagOutput".Trim() } else { 'dev' }
$buildTime = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTimeOffset]::UtcNow, 'China Standard Time').ToString('yyyy-MM-dd HH:mm')
$deployHome = '/home/justme/podman/webdrop'
$configDir = '/home/justme/.config/containers/systemd'
& wsl.exe -d Debian -- mkdir -p $deployHome $configDir
if ($LASTEXITCODE -ne 0) { throw 'Could not create the WSL deployment directories.' }
& wsl.exe -d Debian -- install -D -m 755 $updateWsl "$deployHome/update.sh"
if ($LASTEXITCODE -ne 0) { throw 'Could not install the WSL update script.' }
& wsl.exe -d Debian -- install -D -m 644 $unitWsl "$deployHome/webdrop.container"
if ($LASTEXITCODE -ne 0) { throw 'Could not install the WebDrop Quadlet.' }
& wsl.exe -d Debian -- ln -sfn "$deployHome/webdrop.container" "$configDir/webdrop.container"
if ($LASTEXITCODE -ne 0) { throw 'Could not link the Quadlet into the user systemd directory.' }
& wsl.exe -d Debian -- bash "$deployHome/update.sh" $repoWsl $commit $tag $buildTime
if ($LASTEXITCODE -ne 0) { throw 'The WSL Podman build or service update failed.' }
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -Eeuo pipefail
source_dir="${1:?Pass the workspace path in WSL format}"
frontend_commit="${2:-unknown}"
frontend_tag="${3:-dev}"
build_time="${4:-unknown}"
deploy_dir="$HOME/podman/webdrop"
source_dir="$(realpath "$source_dir")"
# The WSL proxy exports lowercase names; containers/image reads the uppercase names.
export HTTP_PROXY="${HTTP_PROXY:-${http_proxy:-}}"
export HTTPS_PROXY="${HTTPS_PROXY:-${https_proxy:-}}"
export NO_PROXY="${NO_PROXY:-${no_proxy:-}}"
if [[ ! -f "$source_dir/server/Containerfile" ]]; then
echo "Not a WebDrop source tree: $source_dir" >&2
exit 2
fi
mkdir -p "$deploy_dir/src" "$deploy_dir/data"
# Seed the WSL data directory once; later updates leave runtime data untouched.
if [[ -d "$source_dir/data" ]] && [[ -z "$(find "$deploy_dir/data" -mindepth 1 -print -quit)" ]]; then
rsync -a "$source_dir/data/" "$deploy_dir/data/"
fi
# Keep build inputs on WSL's Linux filesystem, not the slower /mnt/c mount.
rsync -a --delete \
--exclude='/.git/' \
--exclude='/.env' --exclude='/.env.*' \
--exclude='/data/' \
--exclude='**/node_modules/' --exclude='**/dist/' \
--exclude='**/bin/' --exclude='**/obj/' \
--exclude='**/*.tsbuildinfo' \
"$source_dir/" "$deploy_dir/src/"
podman build \
--layers \
--platform linux/amd64 \
--format oci \
--file "$deploy_dir/src/server/Containerfile" \
--tag localhost/webdrop:local \
--build-arg "WEBDROP_FRONTEND_COMMIT=$frontend_commit" \
--build-arg "WEBDROP_FRONTEND_TAG=$frontend_tag" \
--build-arg "WEBDROP_BACKEND_COMMIT=$frontend_commit" \
--build-arg "WEBDROP_BACKEND_TAG=$frontend_tag" \
--build-arg "WEBDROP_BUILD_TIME=$build_time" \
"$deploy_dir/src"
systemctl --user daemon-reload
if systemctl --user is-active --quiet webdrop.service; then
systemctl --user restart webdrop.service
else
systemctl --user start webdrop.service
fi
for attempt in {1..30}; do
if curl --fail --silent http://127.0.0.1:5000/api/v1/info >/dev/null; then
echo "WebDrop updated and responding at http://127.0.0.1:5000"
podman ps --filter name=webdrop --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
exit 0
fi
sleep 1
done
systemctl --user status webdrop.service --no-pager || true
journalctl --user -u webdrop.service -n 80 --no-pager || true
exit 1
+22 -4
View File
@@ -1,15 +1,33 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM node:24.21.0-alpine3.23 AS frontend
WORKDIR /src
COPY web/package.json web/package-lock.json ./web/
COPY packages/core/package.json ./packages/core/package.json
RUN cd web && npm ci --no-audit --no-fund
COPY packages/core/ ./packages/core/
COPY web/ ./web/
ARG WEBDROP_FRONTEND_COMMIT=unknown
ARG WEBDROP_FRONTEND_TAG=unknown
ARG WEBDROP_BACKEND_COMMIT=unknown
ARG WEBDROP_BACKEND_TAG=unknown
ARG WEBDROP_BUILD_TIME=unknown
ENV WEBDROP_FRONTEND_COMMIT=$WEBDROP_FRONTEND_COMMIT \
WEBDROP_FRONTEND_TAG=$WEBDROP_FRONTEND_TAG \
WEBDROP_BACKEND_COMMIT=$WEBDROP_BACKEND_COMMIT \
WEBDROP_BACKEND_TAG=$WEBDROP_BACKEND_TAG \
WEBDROP_BUILD_TIME=$WEBDROP_BUILD_TIME
RUN npm run build --prefix web
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble-aot AS build
WORKDIR /src WORKDIR /src
RUN apt-get update \
&& apt-get install -y --no-install-recommends clang zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
COPY server/Directory.Build.props ./server/ COPY server/Directory.Build.props ./server/
COPY server/src/WebDrop.Api/WebDrop.Api.csproj ./server/src/WebDrop.Api/ COPY server/src/WebDrop.Api/WebDrop.Api.csproj ./server/src/WebDrop.Api/
COPY server/src/WebDrop.Core/WebDrop.Core.csproj ./server/src/WebDrop.Core/ COPY server/src/WebDrop.Core/WebDrop.Core.csproj ./server/src/WebDrop.Core/
COPY server/src/WebDrop.Frontend/WebDrop.Frontend.csproj ./server/src/WebDrop.Frontend/
COPY server/src/WebDrop.Storage/WebDrop.Storage.csproj ./server/src/WebDrop.Storage/ COPY server/src/WebDrop.Storage/WebDrop.Storage.csproj ./server/src/WebDrop.Storage/
RUN dotnet restore server/src/WebDrop.Api/WebDrop.Api.csproj \ RUN dotnet restore server/src/WebDrop.Api/WebDrop.Api.csproj \
--runtime linux-x64 --runtime linux-x64
COPY server/ ./server/ COPY server/ ./server/
COPY --from=frontend /src/web/dist/ ./web/dist/
RUN dotnet publish server/src/WebDrop.Api/WebDrop.Api.csproj \ RUN dotnet publish server/src/WebDrop.Api/WebDrop.Api.csproj \
--configuration Release \ --configuration Release \
--runtime linux-x64 \ --runtime linux-x64 \
+3
View File
@@ -8,6 +8,7 @@
|---|---| |---|---|
| `src/WebDrop.Core` | 领域类型、规则和业务逻辑(`ChannelService`、`ShareService`),不做 IO,只依赖抽象 | | `src/WebDrop.Core` | 领域类型、规则和业务逻辑(`ChannelService`、`ShareService`),不做 IO,只依赖抽象 |
| `src/WebDrop.Storage` | SQLite 存储(`SqliteChannelStore`)和磁盘 blob(`DiskBlobStore`) | | `src/WebDrop.Storage` | SQLite 存储(`SqliteChannelStore`)和磁盘 blob(`DiskBlobStore`) |
| `src/WebDrop.Frontend` | 可选嵌入 Vite dist,提供静态文件、SPA fallback 与页面 CSP;不依赖业务层 |
| `src/WebDrop.Api` | Minimal API、WebSocket / SSE / 长轮询、订阅枢纽、后台清理、限流、安全头 | | `src/WebDrop.Api` | Minimal API、WebSocket / SSE / 长轮询、订阅枢纽、后台清理、限流、安全头 |
| `tests/` | `Core.Tests`(单元与服务层,真实 SQLite)、`Api.Tests`(真实 Kestrel 上的集成测试) | | `tests/` | `Core.Tests`(单元与服务层,真实 SQLite)、`Api.Tests`(真实 Kestrel 上的集成测试) |
@@ -16,6 +17,7 @@
```powershell ```powershell
dotnet test # 全部测试 dotnet test # 全部测试
dotnet run --project src/WebDrop.Api # 开发运行,默认 http://localhost:5000 dotnet run --project src/WebDrop.Api # 开发运行,默认 http://localhost:5000
npm --prefix ../web run build # 先构建前端时,dotnet publish 会自动嵌入 web/dist
# NativeAOT 发布(Windows 需要 vswhere.exe 在 PATH 里) # NativeAOT 发布(Windows 需要 vswhere.exe 在 PATH 里)
$env:PATH += ";${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer" $env:PATH += ";${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer"
@@ -47,6 +49,7 @@ python3 scripts/smoke.py out/linux-x64/webdrop # 跨平台版本,只用
- 单实例部署:订阅枢纽在进程内,多实例需要换成外部发布订阅。 - 单实例部署:订阅枢纽在进程内,多实例需要换成外部发布订阅。
- 日志不记录请求头、请求体、完整 URL;Hosting 诊断日志已关闭,避免频道 id 进入日志。 - 日志不记录请求头、请求体、完整 URL;Hosting 诊断日志已关闭,避免频道 id 进入日志。
- NativeAOT:不用 SignalR、EF Core、MVC、反射式 JSON;所有 AOT / 裁剪警告视为错误。 - NativeAOT:不用 SignalR、EF Core、MVC、反射式 JSON;所有 AOT / 裁剪警告视为错误。
- `WebDrop.Frontend` 只负责嵌入静态资源与 SPA fallback。`Containerfile` 自动构建 `web/`;单独发布 API 时,只有 `web/dist` 存在或指定 `WebDropFrontendDist` 后才包含前端。
## 验证码(Cap) ## 验证码(Cap)
+1
View File
@@ -2,6 +2,7 @@
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/WebDrop.Api/WebDrop.Api.csproj" /> <Project Path="src/WebDrop.Api/WebDrop.Api.csproj" />
<Project Path="src/WebDrop.Core/WebDrop.Core.csproj" /> <Project Path="src/WebDrop.Core/WebDrop.Core.csproj" />
<Project Path="src/WebDrop.Frontend/WebDrop.Frontend.csproj" />
<Project Path="src/WebDrop.Storage/WebDrop.Storage.csproj" /> <Project Path="src/WebDrop.Storage/WebDrop.Storage.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
+1 -1
View File
@@ -3,5 +3,5 @@ namespace WebDrop.Api;
public static class ApiContract public static class ApiContract
{ {
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml. // Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
public const string Version = "1.0.0"; public const string Version = "2.1.0";
} }
+90
View File
@@ -47,6 +47,80 @@ public sealed class LimitsBody
[JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; } [JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; }
} }
public sealed class AccountRegisterRequest
{
[JsonPropertyName("public_key")] public required string PublicKey { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("salt")] public required string Salt { get; init; }
[JsonPropertyName("verifier")] public required string Verifier { get; init; }
[JsonPropertyName("vault")] public required string Vault { get; init; }
}
public sealed class AccountSaltRequest { [JsonPropertyName("username")] public required string Username { get; init; } }
public sealed class AccountSessionRequest
{
[JsonPropertyName("device_id")] public string? DeviceId { get; init; }
[JsonPropertyName("device_name")] public string? DeviceName { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("verifier")] public required string Verifier { get; init; }
}
public sealed class AccountVaultRequest
{
[JsonPropertyName("revision")] public required long Revision { get; init; }
[JsonPropertyName("vault")] public required string Vault { get; init; }
}
public sealed class AccountSaltBody
{
[JsonPropertyName("salt")] public required string Salt { get; init; }
}
public sealed class AccountSessionBody
{
[JsonPropertyName("token")] public required string Token { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
}
public sealed class AccountVaultBody
{
[JsonPropertyName("revision")] public required long Revision { get; init; }
[JsonPropertyName("vault")] public required string Vault { get; init; }
}
public sealed class PublicKeyBody { [JsonPropertyName("public_key")] public required string PublicKey { get; init; } }
public sealed class InvitationRequest
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("payload")] public required string Payload { get; init; }
}
public sealed class InvitationBody
{
[JsonPropertyName("channel_id")] public required string ChannelId { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("payload")] public required string Payload { get; init; }
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
}
public sealed class JoinRequest { [JsonPropertyName("password")] public string? Password { get; init; } }
public sealed class KeyPackageRequest { [JsonPropertyName("payload")] public required string Payload { get; init; } }
public sealed class AccountDeviceNameRequest { [JsonPropertyName("name")] public required string Name { get; init; } }
public sealed class AccountDeviceBody
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
[JsonPropertyName("last_seen")] public required long LastSeen { get; init; }
[JsonPropertyName("current")] public required bool Current { get; init; }
}
public sealed class AccountChannelBody
{
[JsonPropertyName("channel_id")] public required string ChannelId { get; init; }
[JsonPropertyName("role")] public required string Role { get; init; }
[JsonPropertyName("payload")] public required string Payload { get; init; }
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
}
public sealed class MemberBody
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("role")] public required string Role { get; init; }
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
}
[JsonSerializable(typeof(ErrorBody))] [JsonSerializable(typeof(ErrorBody))]
[JsonSerializable(typeof(GoneLogBody))] [JsonSerializable(typeof(GoneLogBody))]
[JsonSerializable(typeof(ServerInfoBody))] [JsonSerializable(typeof(ServerInfoBody))]
@@ -62,5 +136,21 @@ public sealed class LimitsBody
[JsonSerializable(typeof(AppendResult))] [JsonSerializable(typeof(AppendResult))]
[JsonSerializable(typeof(SiteverifyRequest))] [JsonSerializable(typeof(SiteverifyRequest))]
[JsonSerializable(typeof(SiteverifyResponse))] [JsonSerializable(typeof(SiteverifyResponse))]
[JsonSerializable(typeof(AccountRegisterRequest))]
[JsonSerializable(typeof(AccountSaltRequest))]
[JsonSerializable(typeof(AccountSessionRequest))]
[JsonSerializable(typeof(AccountVaultRequest))]
[JsonSerializable(typeof(AccountSaltBody))]
[JsonSerializable(typeof(AccountSessionBody))]
[JsonSerializable(typeof(AccountVaultBody))]
[JsonSerializable(typeof(PublicKeyBody))]
[JsonSerializable(typeof(InvitationRequest))]
[JsonSerializable(typeof(List<InvitationBody>))]
[JsonSerializable(typeof(JoinRequest))]
[JsonSerializable(typeof(List<MemberBody>))]
[JsonSerializable(typeof(KeyPackageRequest))]
[JsonSerializable(typeof(List<AccountChannelBody>))]
[JsonSerializable(typeof(AccountDeviceNameRequest))]
[JsonSerializable(typeof(List<AccountDeviceBody>))]
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public sealed partial class ApiJson : JsonSerializerContext; public sealed partial class ApiJson : JsonSerializerContext;
@@ -0,0 +1,91 @@
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static partial class Endpoints
{
private static void MapAccounts(RouteGroupBuilder api, SqliteAccountStore accounts)
{
api.MapPost("/accounts", async (HttpContext ctx, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountRegisterRequest, ct);
var username = NormalizeUsername(req.Username);
var salt = DecodeBase64(req.Salt, 16, 16);
var verifier = DecodeBase64(req.Verifier, 32, 32);
var vault = DecodeBase64(req.Vault, 28, 1_300_000);
ValidatePublicKey(req.PublicKey);
if (!accounts.Register(username, salt, verifier, vault, req.PublicKey)) return Json(new ErrorBody { Error = "username_unavailable" }, ApiJson.Default.ErrorBody, StatusCodes.Status409Conflict);
return Results.NoContent();
}).RequireRateLimiting(CreatePolicy);
api.MapPost("/accounts/salt", async (HttpContext ctx, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSaltRequest, ct);
var username = NormalizeUsername(req.Username);
var salt = accounts.LoginSalt(username);
return Json(new AccountSaltBody { Salt = Convert.ToBase64String(salt) }, ApiJson.Default.AccountSaltBody);
}).RequireRateLimiting(CreatePolicy);
api.MapPost("/accounts/session", async (HttpContext ctx, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSessionRequest, ct);
var username = NormalizeUsername(req.Username);
var verifier = DecodeBase64(req.Verifier, 32, 32);
var token = accounts.Authenticate(username, verifier, req.DeviceId, req.DeviceName);
if (token is null) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized);
return Json(new AccountSessionBody { Token = token, Username = username }, ApiJson.Default.AccountSessionBody);
}).RequireRateLimiting(CreatePolicy);
api.MapGet("/accounts/vault", (HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out var username)) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized);
var vault = accounts.GetVault(username);
return vault is null ? Results.NotFound() : Json(new AccountVaultBody { Revision = vault.Value.Revision, Vault = Convert.ToBase64String(vault.Value.Vault) }, ApiJson.Default.AccountVaultBody);
});
api.MapGet("/accounts/devices", (HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var current = accounts.SessionDevice(ctx.Request.Headers["X-WD-Account"].ToString());
var devices = accounts.Devices(username, current).Select(d => new AccountDeviceBody
{ Id = d.Id, Name = d.Name, CreatedAt = d.CreatedAt, LastSeen = d.LastSeen, Current = d.Current }).ToList();
return Json(devices, ApiJson.Default.ListAccountDeviceBody);
});
api.MapPatch("/accounts/devices/{id}", async (string id, HttpContext ctx, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountDeviceNameRequest, ct);
if (req.Name is null || !accounts.RenameDevice(username, id, req.Name.Trim())) throw new WebDropException(ErrorCode.BadRequest);
return Results.NoContent();
});
api.MapDelete("/accounts/devices/{id}", (string id, HttpContext ctx, ChannelService svc) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
accounts.RevokeDevice(username, id);
svc.ReleaseAccountDevice(username, id);
return Results.NoContent();
});
api.MapPut("/accounts/vault", async (HttpContext ctx, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized);
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountVaultRequest, ct);
if (req.Revision < 0) return Results.BadRequest();
var vault = DecodeBase64(req.Vault, 28, 1_300_000);
var next = accounts.PutVault(username, vault, req.Revision);
return next is null ? Json(new ErrorBody { Error = "vault_conflict" }, ApiJson.Default.ErrorBody, StatusCodes.Status409Conflict) : Json(new AccountVaultBody { Revision = next.Value, Vault = req.Vault }, ApiJson.Default.AccountVaultBody);
});
api.MapDelete("/accounts/session", (HttpContext ctx) =>
{
var accountToken = ctx.Request.Headers["X-WD-Account"].ToString();
if (accountToken.Length > 0) { accounts.RevokeSession(accountToken); return Results.NoContent(); }
var header = ctx.Request.Headers["Authorization"].ToString();
if (!header.StartsWith("Bearer ", StringComparison.Ordinal)) return Results.NoContent();
accounts.RevokeSession(header[7..]);
return Results.NoContent();
});
}
}
@@ -0,0 +1,54 @@
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static partial class Endpoints
{
private static void MapChannelAccess(RouteGroupBuilder api, SqliteAccountStore accounts)
{
api.MapGet("/accounts/channels", (HttpContext ctx, ChannelService svc) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var packages = svc.AccountChannels(username).Select(k => new AccountChannelBody
{
ChannelId = k.ChannelId.ToBase64Url(), Role = k.Role, Payload = k.Payload, CreatedAt = k.CreatedAt,
}).ToList();
return Json(packages, ApiJson.Default.ListAccountChannelBody);
});
api.MapPut("/channels/{id}/key-package", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.KeyPackageRequest, ct);
svc.SaveOwnerKeyPackage(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Payload);
return Results.NoContent();
});
api.MapGet("/accounts/users/{username}/key", (string username, HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out _)) throw new WebDropException(ErrorCode.AccountRequired);
var key = accounts.GetPublicKey(NormalizeUsername(username));
if (key is null) throw new WebDropException(ErrorCode.NotFound);
return Json(new PublicKeyBody { PublicKey = key }, ApiJson.Default.PublicKeyBody);
});
api.MapGet("/accounts/invitations", (HttpContext ctx, ChannelService svc) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
return Json(InvitationBodies(svc.UserInvitations(username)), ApiJson.Default.ListInvitationBody);
});
api.MapGet("/channels/{id}/invitations", (string id, HttpContext ctx, ChannelService svc) =>
Json(InvitationBodies(svc.Invitations(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.ListInvitationBody));
api.MapGet("/channels/{id}/members", (string id, HttpContext ctx, ChannelService svc) =>
Json(svc.Members(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true)).Select(m => new MemberBody { Username = m.Username, Role = m.Role, JoinedAt = m.JoinedAt }).ToList(), ApiJson.Default.ListMemberBody));
api.MapPost("/channels/{id}/invitations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.InvitationRequest, ct);
var username = NormalizeUsername(req.Username);
if (accounts.GetPublicKey(username) is null) throw new WebDropException(ErrorCode.NotFound);
svc.InviteUser(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), username, req.Payload); return Results.NoContent();
});
api.MapDelete("/channels/{id}/invitations/{username}", (string id, string username, HttpContext ctx, ChannelService svc) =>
{
svc.RevokeInvitation(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), NormalizeUsername(username)); return Results.NoContent();
});
}
}
+90 -11
View File
@@ -4,17 +4,18 @@ using System.Net.WebSockets;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization.Metadata; using System.Text.Json.Serialization.Metadata;
using WebDrop.Core; using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api; namespace WebDrop.Api;
public static class Endpoints public static partial class Endpoints
{ {
public const string CreatePolicy = "create"; public const string CreatePolicy = "create";
private const string LastSeqHeader = "X-WD-Last-Seq"; private const string LastSeqHeader = "X-WD-Last-Seq";
private const string OctetStream = "application/octet-stream"; private const string OctetStream = "application/octet-stream";
private static readonly TimeSpan MaxLongPoll = TimeSpan.FromSeconds(25); private static readonly TimeSpan MaxLongPoll = TimeSpan.FromSeconds(25);
public static void MapApi(this IEndpointRouteBuilder app, WebDropOptions options, ICaptchaVerifier captcha) public static void MapApi(this IEndpointRouteBuilder app, WebDropOptions options, ICaptchaVerifier captcha, SqliteAccountStore accounts)
{ {
var api = app.MapGroup("/api/v1"); var api = app.MapGroup("/api/v1");
@@ -37,15 +38,18 @@ public static class Endpoints
}, },
}, ApiJson.Default.ServerInfoBody)); }, ApiJson.Default.ServerInfoBody));
MapAccounts(api, accounts);
// ───────── 频道 ───────── // ───────── 频道 ─────────
api.MapPost("/channels", async (HttpContext ctx, ChannelService svc, CancellationToken ct) => api.MapPost("/channels", async (HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{ {
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.CreateChannelRequest, ct); var req = await ReadJson(ctx.Request, ApiJson.Default.CreateChannelRequest, ct);
var endpoint = RequestAuth.ParseEndpointHash(ctx.Request.Headers[RequestAuth.EndpointHeader]); var endpoint = RequestAuth.ParseEndpointHash(ctx.Request.Headers[RequestAuth.EndpointHeader]);
string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString(); string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString();
await captcha.VerifyAsync(token.Length > 0 ? token : null, ct); await captcha.VerifyAsync(token.Length > 0 ? token : null, ct);
return Json(svc.CreateChannel(req, endpoint), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created); return Json(svc.CreateChannel(req, endpoint, username, accounts.SessionDevice(ctx.Request.Headers["X-WD-Account"].ToString())), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created);
}).RequireRateLimiting(CreatePolicy); }).RequireRateLimiting(CreatePolicy);
api.MapGet("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) => api.MapGet("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) =>
@@ -66,8 +70,13 @@ public static class Endpoints
// ───────── 席位 ───────── // ───────── 席位 ─────────
api.MapPost("/channels/{id}/seats", (string id, HttpContext ctx, ChannelService svc) => api.MapPost("/channels/{id}/seats", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true)), ApiJson.Default.SeatJoined)); {
var request = ctx.Request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) == true ? await ReadJson(ctx.Request, ApiJson.Default.JoinRequest, ct) : new JoinRequest();
return Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), request.Password), ApiJson.Default.SeatJoined);
});
MapChannelAccess(api, accounts);
api.MapGet("/channels/{id}/seats", (string id, HttpContext ctx, ChannelService svc) => api.MapGet("/channels/{id}/seats", (string id, HttpContext ctx, ChannelService svc) =>
{ {
@@ -83,6 +92,14 @@ public static class Endpoints
return Results.NoContent(); return Results.NoContent();
}); });
api.MapPut("/channels/{id}/seats/{endpointHash}/label", async (string id, string endpointHash, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) =>
{
if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest);
byte[] label = await ReadBody(ctx.Request, limits.MaxSeatLabelBytes, ct);
svc.SetSeatLabelForEndpoint(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash, label);
return Results.NoContent();
});
api.MapDelete("/channels/{id}/seats/{endpointHash}", (string id, string endpointHash, HttpContext ctx, ChannelService svc) => api.MapDelete("/channels/{id}/seats/{endpointHash}", (string id, string endpointHash, HttpContext ctx, ChannelService svc) =>
{ {
if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest); if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest);
@@ -97,10 +114,9 @@ public static class Endpoints
var channelId = ParseId(id); var channelId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true); var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString()); long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString());
string label = ctx.Request.Headers["X-WD-Label"].ToString();
byte[] body = await ReadBody(ctx.Request, Envelope.HeaderSize + limits.MaxPayloadBytes, ct); byte[] body = await ReadBody(ctx.Request, Envelope.HeaderSize + limits.MaxPayloadBytes, ct);
var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, label.Length > 0 ? label : null, ct); var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, ct);
return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK); return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK);
}); });
@@ -131,7 +147,7 @@ public static class Endpoints
throw new WebDropException(ErrorCode.BadRequest); throw new WebDropException(ErrorCode.BadRequest);
var channelId = ParseId(id); var channelId = ParseId(id);
var creds = RequestAuth.FromSubprotocols(ctx.WebSockets.WebSocketRequestedProtocols); var creds = RequestAuth.FromSubprotocols(ctx.WebSockets.WebSocketRequestedProtocols, accounts);
long from = QueryLong(ctx.Request, "from", 0); long from = QueryLong(ctx.Request, "from", 0);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted);
@@ -143,7 +159,7 @@ public static class Endpoints
ws = await ctx.WebSockets.AcceptWebSocketAsync("wd.v1"); ws = await ctx.WebSockets.AcceptWebSocketAsync("wd.v1");
_ = WatchClose(ws, cts); _ = WatchClose(ws, cts);
return new WebSocketSink(ws); return new WebSocketSink(ws);
}, cts.Token); }, cts.Token, () => StreamAccountValid(ctx, accounts, creds));
} }
finally finally
{ {
@@ -166,7 +182,7 @@ public static class Endpoints
ctx.Response.Headers["X-Accel-Buffering"] = "no"; ctx.Response.Headers["X-Accel-Buffering"] = "no";
await ctx.Response.StartAsync(); await ctx.Response.StartAsync();
return new SseSink(ctx.Response); return new SseSink(ctx.Response);
}, ctx.RequestAborted); }, ctx.RequestAborted, () => StreamAccountValid(ctx, accounts, creds));
return Results.Empty; return Results.Empty;
}); });
@@ -211,11 +227,12 @@ public static class Endpoints
api.MapPost("/shares", async (HttpContext ctx, ShareService svc, CancellationToken ct) => api.MapPost("/shares", async (HttpContext ctx, ShareService svc, CancellationToken ct) =>
{ {
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.CreateShareRequest, ct); var req = await ReadJson(ctx.Request, ApiJson.Default.CreateShareRequest, ct);
string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString(); string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString();
await captcha.VerifyAsync(token.Length > 0 ? token : null, ct); await captcha.VerifyAsync(token.Length > 0 ? token : null, ct);
byte[] endpoint = RequestAuth.ParseEndpointHashOptional(ctx.Request.Headers[RequestAuth.EndpointHeader]); byte[] endpoint = RequestAuth.ParseEndpointHashOptional(ctx.Request.Headers[RequestAuth.EndpointHeader]);
return Json(svc.Create(req, endpoint.Length == 0 ? null : endpoint), ApiJson.Default.ShareInfo, StatusCodes.Status201Created); return Json(svc.Create(req, endpoint.Length == 0 ? null : endpoint, username), ApiJson.Default.ShareInfo, StatusCodes.Status201Created);
}).RequireRateLimiting(CreatePolicy); }).RequireRateLimiting(CreatePolicy);
api.MapPatch("/shares/{id}/settings", async (string id, HttpContext ctx, ShareService svc, CancellationToken ct) => api.MapPatch("/shares/{id}/settings", async (string id, HttpContext ctx, ShareService svc, CancellationToken ct) =>
@@ -287,6 +304,68 @@ public static class Endpoints
private static IResult Json<T>(T value, JsonTypeInfo<T> info, int? status = null) => private static IResult Json<T>(T value, JsonTypeInfo<T> info, int? status = null) =>
Results.Json(value, info, statusCode: status); Results.Json(value, info, statusCode: status);
private static bool TryAccount(HttpContext ctx, SqliteAccountStore accounts, out string username)
{
username = "";
var accountToken = ctx.Request.Headers["X-WD-Account"].ToString();
if (accountToken.Length > 0) return accounts.HasSession(accountToken, out username);
var header = ctx.Request.Headers["Authorization"].ToString();
return header.StartsWith("Bearer ", StringComparison.Ordinal) && accounts.HasSession(header[7..], out username);
}
private static bool StreamAccountValid(HttpContext ctx, SqliteAccountStore accounts, Credentials creds)
{
if (creds.AccountName is null) return true;
string token = ctx.Request.Headers["X-WD-Account"].ToString();
if (ctx.WebSockets.IsWebSocketRequest)
{
var protocol = ctx.WebSockets.WebSocketRequestedProtocols.FirstOrDefault(p => p.StartsWith("wd.account.", StringComparison.Ordinal));
if (protocol is null || !Codec.TryDecodeExact(protocol[11..], 32, out var raw)) return false;
token = Convert.ToBase64String(raw);
}
return accounts.HasSession(token, out var username) && username == creds.AccountName;
}
private static string NormalizeUsername(string? value)
{
if (value is null) throw new WebDropException(ErrorCode.BadRequest);
var name = value.Trim().ToLowerInvariant();
if (name.Length is < 3 or > 32 || name.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '_' or '-')))
throw new WebDropException(ErrorCode.BadRequest);
return name;
}
private static List<InvitationBody> InvitationBodies(IReadOnlyList<ChannelInvitation> invitations) => invitations.Select(i => new InvitationBody
{
ChannelId = i.ChannelId.ToBase64Url(), Username = i.Username, Payload = i.Payload, CreatedAt = i.CreatedAt,
}).ToList();
private static void ValidatePublicKey(string? value)
{
if (value is null) throw new WebDropException(ErrorCode.BadRequest);
if (value.Length > 1024) throw new WebDropException(ErrorCode.BadRequest);
try
{
using var doc = JsonDocument.Parse(value); var root = doc.RootElement;
if (root.GetProperty("kty").GetString() != "EC" || root.GetProperty("crv").GetString() != "P-256"
|| !Codec.TryDecodeExact(root.GetProperty("x").GetString(), 32, out _)
|| !Codec.TryDecodeExact(root.GetProperty("y").GetString(), 32, out _) || root.TryGetProperty("d", out _))
throw new WebDropException(ErrorCode.BadRequest);
}
catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException) { throw new WebDropException(ErrorCode.BadRequest); }
}
private static byte[] DecodeBase64(string? value, int minLength, int maxLength)
{
if (value is null || value.Length > ((maxLength + 2) / 3 * 4)) throw new WebDropException(ErrorCode.BadRequest);
try
{
var bytes = Convert.FromBase64String(value);
return bytes.Length >= minLength && bytes.Length <= maxLength ? bytes : throw new WebDropException(ErrorCode.BadRequest);
}
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
}
private static Id16 ParseId(string text) => private static Id16 ParseId(string text) =>
Id16.TryParse(text, out var id) ? id : throw new WebDropException(ErrorCode.BadRequest); Id16.TryParse(text, out var id) ? id : throw new WebDropException(ErrorCode.BadRequest);
+2 -1
View File
@@ -25,7 +25,7 @@ public static class LogStreamer
/// </summary> /// </summary>
public static async Task RunAsync( public static async Task RunAsync(
ChannelService svc, ChannelHub hub, Id16 id, Credentials creds, long from, ChannelService svc, ChannelHub hub, Id16 id, Credentials creds, long from,
Func<Task<ILogSink>> open, CancellationToken ct) Func<Task<ILogSink>> open, CancellationToken ct, Func<bool>? validateAccount = null)
{ {
using var sub = hub.Subscribe(id); using var sub = hub.Subscribe(id);
ILogSink? sink = null; ILogSink? sink = null;
@@ -36,6 +36,7 @@ public static class LogStreamer
{ {
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)
{ {
if (validateAccount is not null && !validateAccount()) throw new WebDropException(ErrorCode.AccountRequired);
var changed = sub.Changed; // 先取任务再读状态:读取期间的发布不会丢 var changed = sub.Changed; // 先取任务再读状态:读取期间的发布不会丢
var page = await svc.ReadLogAsync(id, creds, cursor, PageSize, ct).ConfigureAwait(false); var page = await svc.ReadLogAsync(id, creds, cursor, PageSize, ct).ConfigureAwait(false);
sink ??= await open().ConfigureAwait(false); sink ??= await open().ConfigureAwait(false);
+28 -4
View File
@@ -1,4 +1,5 @@
using WebDrop.Core; using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api; namespace WebDrop.Api;
@@ -23,28 +24,51 @@ public static class RequestAuth
throw new WebDropException(ErrorCode.BadRequest); throw new WebDropException(ErrorCode.BadRequest);
} }
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint)); string? accountName = null;
string? deviceId = null;
var accountToken = req.Headers["X-WD-Account"].ToString();
if (accountToken.Length > 0)
{
var accounts = req.HttpContext.RequestServices.GetRequiredService<SqliteAccountStore>();
if (!accounts.HasSession(accountToken, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
accountName = username;
deviceId = accounts.SessionDevice(accountToken);
}
if (role == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint), accountName, deviceId);
} }
/// <summary> /// <summary>
/// 浏览器的 WebSocket 不能设请求头,凭据放在子协议里: /// 浏览器的 WebSocket 不能设请求头,凭据放在子协议里:
/// wd.v1, wd.role.&lt;role&gt;, wd.auth.&lt;b64url&gt;, wd.ep.&lt;b64url&gt; /// wd.v1, wd.role.&lt;role&gt;, wd.auth.&lt;b64url&gt;, wd.ep.&lt;b64url&gt;
/// </summary> /// </summary>
public static Credentials FromSubprotocols(IEnumerable<string> protocols) public static Credentials FromSubprotocols(IEnumerable<string> protocols, SqliteAccountStore? accounts = null)
{ {
string? role = null, auth = null, endpoint = null; string? role = null, auth = null, endpoint = null, accountToken = null;
foreach (string p in protocols) foreach (string p in protocols)
{ {
if (p.StartsWith("wd.role.", StringComparison.Ordinal)) role = p["wd.role.".Length..]; if (p.StartsWith("wd.role.", StringComparison.Ordinal)) role = p["wd.role.".Length..];
else if (p.StartsWith("wd.auth.", StringComparison.Ordinal)) auth = p["wd.auth.".Length..]; else if (p.StartsWith("wd.auth.", StringComparison.Ordinal)) auth = p["wd.auth.".Length..];
else if (p.StartsWith("wd.ep.", StringComparison.Ordinal)) endpoint = p["wd.ep.".Length..]; else if (p.StartsWith("wd.ep.", StringComparison.Ordinal)) endpoint = p["wd.ep.".Length..];
else if (p.StartsWith("wd.account.", StringComparison.Ordinal)) accountToken = p["wd.account.".Length..];
} }
byte[]? token = null; byte[]? token = null;
if (auth is not null && !Codec.TryDecodeExact(auth, TokenHash.Size, out token)) if (auth is not null && !Codec.TryDecodeExact(auth, TokenHash.Size, out token))
throw new WebDropException(ErrorCode.BadRequest); throw new WebDropException(ErrorCode.BadRequest);
return new Credentials(ParseRole(role), token, ParseEndpoint(endpoint, requireEndpoint: true)); string? accountName = null;
string? deviceId = null;
if (accountToken is not null)
{
if (accounts is null || !Codec.TryDecodeExact(accountToken, 32, out var raw)
|| !accounts.HasSession(Convert.ToBase64String(raw), out var username)) throw new WebDropException(ErrorCode.AccountRequired);
accountName = username;
deviceId = accounts.SessionDevice(Convert.ToBase64String(raw));
}
var parsedRole = ParseRole(role);
if (parsedRole == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: true), accountName, deviceId);
} }
public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true); public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true);
@@ -11,6 +11,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\WebDrop.Core\WebDrop.Core.csproj" /> <ProjectReference Include="..\WebDrop.Core\WebDrop.Core.csproj" />
<ProjectReference Include="..\WebDrop.Frontend\WebDrop.Frontend.csproj" />
<ProjectReference Include="..\WebDrop.Storage\WebDrop.Storage.csproj" /> <ProjectReference Include="..\WebDrop.Storage\WebDrop.Storage.csproj" />
</ItemGroup> </ItemGroup>
+9 -5
View File
@@ -1,5 +1,6 @@
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using WebDrop.Core; using WebDrop.Core;
using WebDrop.Frontend;
using WebDrop.Storage; using WebDrop.Storage;
namespace WebDrop.Api; namespace WebDrop.Api;
@@ -23,8 +24,9 @@ public static class WebDropApp
builder.Services.AddSingleton(options); builder.Services.AddSingleton(options);
builder.Services.AddSingleton(options.Limits); builder.Services.AddSingleton(options.Limits);
builder.Services.AddSingleton<IClock, SystemClock>(); builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<IBlobStore>(_ => new DiskBlobStore(Path.Combine(options.DataDir, "blobs"))); builder.Services.AddSingleton<IBlobStore>(_ => new DiskBlobStore(Path.Combine(options.DataDir, "v2", "blobs")));
builder.Services.AddSingleton<SqliteChannelStore>(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "webdrop.db"))); builder.Services.AddSingleton<SqliteChannelStore>(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "v2", "webdrop.db")));
builder.Services.AddSingleton<SqliteAccountStore>(_ => new SqliteAccountStore(Path.Combine(options.DataDir, "v2", "webdrop.db")));
builder.Services.AddSingleton<IResourceStore>(sp => sp.GetRequiredService<SqliteChannelStore>()); builder.Services.AddSingleton<IResourceStore>(sp => sp.GetRequiredService<SqliteChannelStore>());
builder.Services.AddSingleton<ICaptchaVerifier>(sp => CreateCaptchaVerifier(options.Captcha, sp)); builder.Services.AddSingleton<ICaptchaVerifier>(sp => CreateCaptchaVerifier(options.Captcha, sp));
builder.Services.AddSingleton<ChannelHub>(); builder.Services.AddSingleton<ChannelHub>();
@@ -41,7 +43,7 @@ public static class WebDropApp
.WithOrigins(options.CorsOrigins) .WithOrigins(options.CorsOrigins)
.AllowAnyMethod() .AllowAnyMethod()
.WithHeaders("Authorization", "Content-Type", "Last-Event-ID", .WithHeaders("Authorization", "Content-Type", "Last-Event-ID",
"X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Label", "X-WD-Max-Downloads") "X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Max-Downloads", "X-WD-Account")
.WithExposedHeaders("X-WD-Last-Seq") .WithExposedHeaders("X-WD-Last-Seq")
.SetPreflightMaxAge(TimeSpan.FromHours(1)))); .SetPreflightMaxAge(TimeSpan.FromHours(1))));
} }
@@ -63,6 +65,7 @@ public static class WebDropApp
}); });
var app = builder.Build(); var app = builder.Build();
_ = app.Services.GetRequiredService<SqliteChannelStore>();
if (options.TrustForwardedHeaders) if (options.TrustForwardedHeaders)
{ {
@@ -80,7 +83,8 @@ public static class WebDropApp
if (options.CorsOrigins.Length > 0) app.UseCors(); if (options.CorsOrigins.Length > 0) app.UseCors();
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat }); app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat });
app.UseRateLimiter(); app.UseRateLimiter();
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>()); EmbeddedFrontend.Use(app, options.Captcha.ApiEndpoint);
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>(), app.Services.GetRequiredService<SqliteAccountStore>());
return app; return app;
} }
@@ -114,7 +118,7 @@ public static class WebDropApp
QueueLimit = 0, QueueLimit = 0,
}; };
/// <summary>API 不提供任何页面。禁用缓存、禁止嗅探、不发送 Referer、不允许被嵌入。</summary> /// <summary>API 默认禁止缓存、嗅探和嵌入;嵌入式前端的页面策略由静态模块单独设置。</summary>
private static Task SecurityHeaders(HttpContext ctx, RequestDelegate next) private static Task SecurityHeaders(HttpContext ctx, RequestDelegate next)
{ {
var h = ctx.Response.Headers; var h = ctx.Response.Headers;
+21
View File
@@ -92,6 +92,26 @@ public interface IResourceTx
Channel? GetChannel(Id16 id); Channel? GetChannel(Id16 id);
void InsertChannel(Channel channel); void InsertChannel(Channel channel);
void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt); void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt);
void SetJoinPassword(Id16 id, byte[]? hash, byte[]? salt);
bool HasInvitation(Id16 id, string username);
void PutInvitation(ChannelInvitation invitation);
IReadOnlyList<ChannelInvitation> ListInvitations(Id16 id);
IReadOnlyList<ChannelInvitation> ListUserInvitations(string username);
void DeleteInvitation(Id16 id, string username);
void PutKeyPackage(Id16 id, string username, string payload, long createdAt);
void DeleteKeyPackage(Id16 id, string username);
IReadOnlyList<ChannelKeyPackage> ListUserKeyPackages(string username, long now);
IReadOnlyList<Id16> DeleteDeviceSeats(string username, string deviceId);
void BindSeatDevice(Id16 id, byte[] endpointHash, string deviceId);
void DeleteAccountSeats(Id16 id, string username);
ChannelMember? GetChannelMember(Id16 id, string username);
IReadOnlyList<ChannelMember> ListChannelMembers(Id16 id);
void PutChannelMember(Id16 id, ChannelMember member);
void DeleteChannelMember(Id16 id, string username);
bool IsJoinDenied(Id16 id, string? username, byte[] endpointHash);
void DenyAccount(Id16 id, string username);
void AllowAccount(Id16 id, string username);
void DenyEndpoint(Id16 id, byte[] endpointHash);
void TouchChannel(Id16 id, long expiresAt); void TouchChannel(Id16 id, long expiresAt);
void AddChannelBytes(Id16 id, long delta); void AddChannelBytes(Id16 id, long delta);
void SetCompactedThrough(Id16 id, long seq); void SetCompactedThrough(Id16 id, long seq);
@@ -113,6 +133,7 @@ public interface IResourceTx
void UpdateItem(Item item); void UpdateItem(Item item);
void DeleteItemRows(Id16 channelId, Id16 itemId); void DeleteItemRows(Id16 channelId, Id16 itemId);
int CountItems(Id16 channelId); int CountItems(Id16 channelId);
int CountControlItems(Id16 channelId);
IReadOnlyList<Item> ListOldestItems(Id16 channelId, int limit); IReadOnlyList<Item> ListOldestItems(Id16 channelId, int limit);
Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId); Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId);
IReadOnlyList<Item> ListExpiredItems(long now, int limit); IReadOnlyList<Item> ListExpiredItems(long now, int limit);
@@ -0,0 +1,99 @@
using System.Security.Cryptography;
namespace WebDrop.Core;
public sealed partial class ChannelService
{
private static (byte[]? Hash, byte[]? Salt) HashJoinPassword(string? password)
{
if (string.IsNullOrEmpty(password)) return (null, null);
if (password.Length is < 8 or > 256) throw new WebDropException(ErrorCode.BadRequest);
var salt = RandomNumberGenerator.GetBytes(16);
return (Rfc2898DeriveBytes.Pbkdf2(password, salt, 210_000, HashAlgorithmName.SHA256, 32), salt);
}
private static void CheckAdmission(IResourceTx tx, Channel channel, Credentials creds, string? password)
{
if (creds.AccountName is not null && creds.AccountName == channel.CreatorAccount) return;
if (tx.IsJoinDenied(channel.Id, creds.AccountName, creds.EndpointHash)) throw new WebDropException(ErrorCode.Forbidden);
bool invited = creds.AccountName is not null && tx.HasInvitation(channel.Id, creds.AccountName);
if (invited) return;
if (creds.AccountName is not null && tx.GetChannelMember(channel.Id, creds.AccountName) is not null) return;
if (channel.Settings.JoinPolicy == "invite") throw new WebDropException(ErrorCode.InvitationRequired);
if (!channel.Settings.AllowGuests && creds.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired);
if (channel.JoinPasswordHash is null) return;
if (string.IsNullOrEmpty(password)) throw new WebDropException(ErrorCode.ChannelPasswordRequired);
if (password.Length > 256) throw new WebDropException(ErrorCode.ChannelPasswordInvalid);
var actual = Rfc2898DeriveBytes.Pbkdf2(password, channel.JoinPasswordSalt!, 210_000, HashAlgorithmName.SHA256, 32);
if (!CryptographicOperations.FixedTimeEquals(actual, channel.JoinPasswordHash)) throw new WebDropException(ErrorCode.ChannelPasswordInvalid);
}
public void InviteUser(Id16 id, Credentials creds, string username, string payload)
{
if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
tx.PutInvitation(new ChannelInvitation(id, username, payload, clock.NowMs));
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
tx.PutKeyPackage(id, username, payload, clock.NowMs);
tx.AllowAccount(id, username);
return 0;
});
}
public IReadOnlyList<ChannelInvitation> Invitations(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
return tx.ListInvitations(id);
});
public IReadOnlyList<ChannelInvitation> UserInvitations(string username) => store.Read(tx => tx.ListUserInvitations(username));
public void RevokeInvitation(Id16 id, Credentials creds, string username)
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
tx.DeleteInvitation(id, username);
tx.DeleteKeyPackage(id, username);
tx.DeleteAccountSeats(id, username);
tx.DeleteChannelMember(id, username);
tx.DenyAccount(id, username);
return 0;
});
events.SeatsChanged(id);
}
public IReadOnlyList<ChannelMember> Members(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
if (principal.Role != Role.Owner) _ = Authenticate(tx, id, creds, SeatRule.Required);
return tx.ListChannelMembers(id);
});
public IReadOnlyList<ChannelKeyPackage> AccountChannels(string username) =>
store.Read(tx => tx.ListUserKeyPackages(username, clock.NowMs));
public void ReleaseAccountDevice(string username, string deviceId)
{
var channels = store.Write(tx => tx.DeleteDeviceSeats(username, deviceId));
foreach (var id in channels) events.SeatsChanged(id);
}
public void SaveOwnerKeyPackage(Id16 id, Credentials creds, string payload)
{
if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
tx.PutKeyPackage(id, principal.AccountName!, payload, clock.NowMs);
return 0;
});
}
}
+3 -1
View File
@@ -19,7 +19,9 @@ public static class ChannelPolicy
return channel.Settings.DeletePolicy switch return channel.Settings.DeletePolicy switch
{ {
DeletePolicy.Anyone => true, DeletePolicy.Anyone => true,
DeletePolicy.Own => item.AuthorHash.AsSpan().SequenceEqual(p.EndpointHash), DeletePolicy.Own => item.AuthorAccount is not null
? item.AuthorAccount == p.AccountName
: item.AuthorHash.AsSpan().SequenceEqual(p.EndpointHash),
_ => false, _ => false,
}; };
} }
+67 -17
View File
@@ -6,7 +6,7 @@ namespace WebDrop.Core;
/// 频道的全部业务逻辑。依赖抽象(存储、blob、时钟、事件),不含 HTTP、SQL、文件细节。 /// 频道的全部业务逻辑。依赖抽象(存储、blob、时钟、事件),不含 HTTP、SQL、文件细节。
/// 约定:所有失败以 <see cref="WebDropException"/> 表达;提交成功后才发事件;blob 先写后提交。 /// 约定:所有失败以 <see cref="WebDropException"/> 表达;提交成功后才发事件;blob 先写后提交。
/// </summary> /// </summary>
public sealed class ChannelService( public sealed partial class ChannelService(
IResourceStore store, IResourceStore store,
IBlobStore blobs, IBlobStore blobs,
IClock clock, IClock clock,
@@ -17,8 +17,10 @@ public sealed class ChannelService(
// ───────────────────────── 频道 ───────────────────────── // ───────────────────────── 频道 ─────────────────────────
public ChannelInfo CreateChannel(CreateChannelRequest req, byte[] endpointHash) public ChannelInfo CreateChannel(CreateChannelRequest req, byte[] endpointHash, string creatorAccount, string? accountDeviceId = null)
{ {
if (string.IsNullOrWhiteSpace(creatorAccount)) throw new WebDropException(ErrorCode.AccountRequired);
if (req.KeyPackage is not null && req.KeyPackage.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
if (!Id16.TryParse(req.Id, out var id)) throw new WebDropException(ErrorCode.BadRequest); if (!Id16.TryParse(req.Id, out var id)) throw new WebDropException(ErrorCode.BadRequest);
if (req.Mode is not { } mode) throw new WebDropException(ErrorCode.BadRequest); if (req.Mode is not { } mode) throw new WebDropException(ErrorCode.BadRequest);
@@ -44,14 +46,17 @@ public sealed class ChannelService(
throw new WebDropException(ErrorCode.BadRequest); throw new WebDropException(ErrorCode.BadRequest);
long now = clock.NowMs; long now = clock.NowMs;
var (passwordHash, passwordSalt) = HashJoinPassword(req.JoinPassword);
var channel = new Channel(id, mode, settings, readHash, writeHash, ownerHash, var channel = new Channel(id, mode, settings, readHash, writeHash, ownerHash,
now, now + settings.TtlSeconds * 1000L, 0, 0, 0); now, now + settings.TtlSeconds * 1000L, 0, 0, 0, creatorAccount, passwordHash, passwordSalt);
store.Write(tx => store.Write(tx =>
{ {
if (tx.HasResourceId(id)) throw new WebDropException(ErrorCode.Conflict); if (tx.HasResourceId(id)) throw new WebDropException(ErrorCode.Conflict);
tx.InsertChannel(channel); tx.InsertChannel(channel);
tx.InsertSeat(id, new Seat(endpointHash, now, null)); tx.PutChannelMember(id, new ChannelMember(creatorAccount, "owner", now));
if (req.KeyPackage is not null) tx.PutKeyPackage(id, creatorAccount, req.KeyPackage, now);
tx.InsertSeat(id, new Seat(endpointHash, now, null, creatorAccount, accountDeviceId));
return 0; return 0;
}); });
return ToInfo(channel, 1); return ToInfo(channel, 1);
@@ -77,6 +82,12 @@ public sealed class ChannelService(
? clock.NowMs + updated.TtlSeconds * 1000L ? clock.NowMs + updated.TtlSeconds * 1000L
: channel.ExpiresAt; : channel.ExpiresAt;
tx.UpdateChannel(id, updated, expiresAt); tx.UpdateChannel(id, updated, expiresAt);
if (patch.JoinPassword is not null)
{
var (hash, salt) = HashJoinPassword(patch.JoinPassword);
tx.SetJoinPassword(id, hash, salt);
channel = channel with { JoinPasswordHash = hash, JoinPasswordSalt = salt };
}
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountSeats(id)); return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountSeats(id));
}); });
events.SettingsChanged(id); events.SettingsChanged(id);
@@ -98,19 +109,28 @@ public sealed class ChannelService(
// ───────────────────────── 席位 ───────────────────────── // ───────────────────────── 席位 ─────────────────────────
public SeatJoined Join(Id16 id, Credentials creds) public SeatJoined Join(Id16 id, Credentials creds, string? password = null)
{ {
var (joined, isNew) = store.Write(tx => var (joined, isNew) = store.Write(tx =>
{ {
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None); var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
var existing = tx.GetSeat(id, principal.EndpointHash); var existing = tx.GetSeat(id, principal.EndpointHash);
if (existing?.AccountName is not null && existing.AccountName != creds.AccountName) throw new WebDropException(ErrorCode.AccountRequired);
if (existing?.AccountDeviceId is not null && existing.AccountDeviceId != creds.AccountDeviceId) throw new WebDropException(ErrorCode.AccountRequired);
int used = tx.CountSeats(id); int used = tx.CountSeats(id);
if (existing is not null) if (existing is not null)
{
if (existing.AccountName is not null && existing.AccountDeviceId is null && creds.AccountDeviceId is not null)
tx.BindSeatDevice(id, principal.EndpointHash, creds.AccountDeviceId);
return (new SeatJoined { EndpointHash = Codec.Encode(existing.EndpointHash), JoinedAt = existing.JoinedAt, SeatsUsed = used, SeatsMax = channel.Settings.Seats }, false); return (new SeatJoined { EndpointHash = Codec.Encode(existing.EndpointHash), JoinedAt = existing.JoinedAt, SeatsUsed = used, SeatsMax = channel.Settings.Seats }, false);
}
CheckAdmission(tx, channel, creds, password);
if (used >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull); if (used >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull);
long now = clock.NowMs; long now = clock.NowMs;
tx.InsertSeat(id, new Seat(principal.EndpointHash, now, null)); tx.InsertSeat(id, new Seat(principal.EndpointHash, now, null, creds.AccountName, creds.AccountDeviceId));
if (creds.AccountName is not null) tx.PutChannelMember(id, new ChannelMember(creds.AccountName, "member", now));
return (new SeatJoined { EndpointHash = Codec.Encode(principal.EndpointHash), JoinedAt = now, SeatsUsed = used + 1, SeatsMax = channel.Settings.Seats }, true); return (new SeatJoined { EndpointHash = Codec.Encode(principal.EndpointHash), JoinedAt = now, SeatsUsed = used + 1, SeatsMax = channel.Settings.Seats }, true);
}); });
if (isNew) events.SeatsChanged(id); if (isNew) events.SeatsChanged(id);
@@ -126,13 +146,13 @@ public sealed class ChannelService(
EndpointHash = Codec.Encode(s.EndpointHash), EndpointHash = Codec.Encode(s.EndpointHash),
JoinedAt = s.JoinedAt, JoinedAt = s.JoinedAt,
Label = s.Label is null ? null : Codec.Encode(s.Label), Label = s.Label is null ? null : Codec.Encode(s.Label),
AccountName = s.AccountName,
}).ToList(); }).ToList();
}); });
public void SetSeatLabel(Id16 id, Credentials creds, byte[] label) public void SetSeatLabel(Id16 id, Credentials creds, byte[] label)
{ {
if (label.Length == 0) throw new WebDropException(ErrorCode.BadRequest); ValidateSeatLabel(label);
if (label.Length > limits.MaxSeatLabelBytes) throw new WebDropException(ErrorCode.TooLarge);
store.Write(tx => store.Write(tx =>
{ {
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required);
@@ -142,6 +162,26 @@ public sealed class ChannelService(
events.SeatsChanged(id); events.SeatsChanged(id);
} }
public void SetSeatLabelForEndpoint(Id16 id, Credentials creds, byte[] endpointHash, byte[] label)
{
ValidateSeatLabel(label);
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
if (tx.GetSeat(id, endpointHash) is null) throw new WebDropException(ErrorCode.NotFound);
tx.SetSeatLabel(id, endpointHash, label);
return 0;
});
events.SeatsChanged(id);
}
private void ValidateSeatLabel(byte[] label)
{
if (label.Length == 0) throw new WebDropException(ErrorCode.BadRequest);
if (label.Length > limits.MaxSeatLabelBytes) throw new WebDropException(ErrorCode.TooLarge);
}
public void ReleaseSeat(Id16 id, Credentials creds, byte[] endpointHash) public void ReleaseSeat(Id16 id, Credentials creds, byte[] endpointHash)
{ {
store.Write(tx => store.Write(tx =>
@@ -149,6 +189,7 @@ public sealed class ChannelService(
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal); RequireOwner(principal);
if (!tx.DeleteSeat(id, endpointHash)) throw new WebDropException(ErrorCode.NotFound); if (!tx.DeleteSeat(id, endpointHash)) throw new WebDropException(ErrorCode.NotFound);
tx.DenyEndpoint(id, endpointHash);
return 0; return 0;
}); });
events.SeatsChanged(id); events.SeatsChanged(id);
@@ -158,12 +199,10 @@ public sealed class ChannelService(
public async Task<AppendResult> AppendFrameAsync( public async Task<AppendResult> AppendFrameAsync(
Id16 channelId, Credentials creds, ReadOnlyMemory<byte> body, Id16 channelId, Credentials creds, ReadOnlyMemory<byte> body,
long? maxDownloads, string? label, CancellationToken ct = default) long? maxDownloads, CancellationToken ct = default)
{ {
if (!Envelope.TryParse(body.Span, out var env)) throw new WebDropException(ErrorCode.BadRequest); if (!Envelope.TryParse(body.Span, out var env)) throw new WebDropException(ErrorCode.BadRequest);
if (env.PayloadLength > limits.MaxPayloadBytes) throw new WebDropException(ErrorCode.TooLarge); if (env.PayloadLength > limits.MaxPayloadBytes) throw new WebDropException(ErrorCode.TooLarge);
if (label is not null && System.Text.Encoding.UTF8.GetByteCount(label) > limits.MaxLabelBytes)
throw new WebDropException(ErrorCode.BadRequest);
ValidateShape(env); ValidateShape(env);
byte[] digest = SHA256.HashData(body.Span); byte[] digest = SHA256.HashData(body.Span);
@@ -189,7 +228,7 @@ public sealed class ChannelService(
AppendOutcome outcome; AppendOutcome outcome;
try try
{ {
outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads, label)); outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads));
} }
catch catch
{ {
@@ -212,7 +251,7 @@ public sealed class ChannelService(
private AppendOutcome AppendInTx( private AppendOutcome AppendInTx(
IResourceTx tx, Id16 channelId, Credentials creds, Envelope env, ReadOnlyMemory<byte> body, IResourceTx tx, Id16 channelId, Credentials creds, Envelope env, ReadOnlyMemory<byte> body,
byte[] digest, string? blobKey, long? maxDownloads, string? label) byte[] digest, string? blobKey, long? maxDownloads)
{ {
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required); var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
CheckWritable(channel, principal, env); CheckWritable(channel, principal, env);
@@ -227,10 +266,11 @@ public sealed class ChannelService(
if (env.FrameIndex == 0) if (env.FrameIndex == 0)
{ {
if (env.IsControl && tx.CountControlItems(channelId) >= 4096) throw new WebDropException(ErrorCode.TooLarge);
if (item is not null) throw new WebDropException(ErrorCode.BadRequest); if (item is not null) throw new WebDropException(ErrorCode.BadRequest);
long max = env.IsGated ? Math.Clamp(maxDownloads ?? channel.Settings.FileMaxDownloads, 0, 1_000_000) : 0; long max = env.IsGated ? Math.Clamp(maxDownloads ?? channel.Settings.FileMaxDownloads, 0, 1_000_000) : 0;
item = new Item(channelId, env.ItemId, principal.EndpointHash, env.ItemFlags, max, 0, 0, -1, null, 0, item = new Item(channelId, env.ItemId, principal.EndpointHash, env.ItemFlags, max, 0, 0, -1, null, 0,
now, now + ttlMs, null, label); now, env.IsControl ? long.MaxValue : now + ttlMs, null, principal.AccountName);
tx.InsertItem(item); tx.InsertItem(item);
} }
else else
@@ -305,6 +345,7 @@ public sealed class ChannelService(
private void ValidateShape(Envelope env) private void ValidateShape(Envelope env)
{ {
if (env.IsControl && (env.IsGated || env.IsSnapshot || env.FrameIndex != 0 || !env.IsFinal || env.PayloadLength > 16_400)) throw new WebDropException(ErrorCode.BadRequest);
// 非 GATED 条目只能是单帧且必须 FINAL;快照只能是非 GATED 的单帧 // 非 GATED 条目只能是单帧且必须 FINAL;快照只能是非 GATED 的单帧
if (!env.IsGated && (env.FrameIndex != 0 || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest); if (!env.IsGated && (env.FrameIndex != 0 || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest);
if (env.IsSnapshot && env.IsGated) throw new WebDropException(ErrorCode.BadRequest); if (env.IsSnapshot && env.IsGated) throw new WebDropException(ErrorCode.BadRequest);
@@ -541,9 +582,16 @@ public sealed class ChannelService(
if (channel is null || channel.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.NotFound); if (channel is null || channel.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.NotFound);
var role = VerifyRole(channel, creds); var role = VerifyRole(channel, creds);
if (seat == SeatRule.Required && tx.GetSeat(id, creds.EndpointHash) is null) var member = creds.AccountName is null ? null : tx.GetChannelMember(id, creds.AccountName);
throw new WebDropException(ErrorCode.NotJoined); if (role == Role.Owner && member?.Role != "owner") throw new WebDropException(ErrorCode.AccountRequired);
return (channel, new Principal(role, creds.EndpointHash)); if (seat == SeatRule.Required)
{
var endpoint = tx.GetSeat(id, creds.EndpointHash);
if (endpoint is null) throw new WebDropException(ErrorCode.NotJoined);
if (endpoint.AccountName is not null && (endpoint.AccountName != creds.AccountName || member is null)) throw new WebDropException(ErrorCode.AccountRequired);
if (endpoint.AccountDeviceId is not null && endpoint.AccountDeviceId != creds.AccountDeviceId) throw new WebDropException(ErrorCode.AccountRequired);
}
return (channel, new Principal(role, creds.EndpointHash, creds.AccountName));
} }
private static Role VerifyRole(Channel channel, Credentials creds) private static Role VerifyRole(Channel channel, Credentials creds)
@@ -573,6 +621,8 @@ public sealed class ChannelService(
private static ChannelInfo ToInfo(Channel channel, int seatsUsed) => new() private static ChannelInfo ToInfo(Channel channel, int seatsUsed) => new()
{ {
Id = channel.Id.ToBase64Url(), Id = channel.Id.ToBase64Url(),
CreatorAccount = channel.CreatorAccount,
PasswordRequired = channel.JoinPasswordHash is not null,
Mode = channel.Mode, Mode = channel.Mode,
Settings = channel.Settings, Settings = channel.Settings,
SeatsUsed = seatsUsed, SeatsUsed = seatsUsed,
+6
View File
@@ -5,6 +5,8 @@ namespace WebDrop.Core;
public sealed class CreateChannelRequest public sealed class CreateChannelRequest
{ {
[JsonPropertyName("key_package")] public string? KeyPackage { get; init; }
[JsonPropertyName("join_password")] public string? JoinPassword { get; init; }
[JsonPropertyName("id")] public string? Id { get; init; } [JsonPropertyName("id")] public string? Id { get; init; }
[JsonPropertyName("mode")] public ChannelMode? Mode { get; init; } [JsonPropertyName("mode")] public ChannelMode? Mode { get; init; }
[JsonPropertyName("settings")] public ChannelSettings? Settings { get; init; } [JsonPropertyName("settings")] public ChannelSettings? Settings { get; init; }
@@ -40,6 +42,7 @@ public sealed class ShareInfo
public sealed class ShareItemInfo public sealed class ShareItemInfo
{ {
[JsonPropertyName("complete")] public bool Complete { get; init; }
[JsonPropertyName("item_id")] public required string ItemId { get; init; } [JsonPropertyName("item_id")] public required string ItemId { get; init; }
[JsonPropertyName("bytes")] public required long Bytes { get; init; } [JsonPropertyName("bytes")] public required long Bytes { get; init; }
[JsonPropertyName("frames")] public required int Frames { get; init; } [JsonPropertyName("frames")] public required int Frames { get; init; }
@@ -49,6 +52,8 @@ public sealed class ShareItemInfo
public sealed class ChannelInfo public sealed class ChannelInfo
{ {
[JsonPropertyName("password_required")] public bool PasswordRequired { get; init; }
[JsonPropertyName("creator_account")] public string? CreatorAccount { get; init; }
[JsonPropertyName("id")] public required string Id { get; init; } [JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; } [JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; } [JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
@@ -72,6 +77,7 @@ public sealed class SeatJoined
public sealed class SeatInfo public sealed class SeatInfo
{ {
[JsonPropertyName("account_name")] public string? AccountName { get; init; }
[JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; } [JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; }
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; } [JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
[JsonPropertyName("label")] public string? Label { get; init; } [JsonPropertyName("label")] public string? Label { get; init; }
+1
View File
@@ -34,6 +34,7 @@ public enum FrameFlags : byte
Snapshot = 2, Snapshot = 2,
Gated = 4, Gated = 4,
Enc = 8, Enc = 8,
Control = 16,
} }
public enum LogKind : byte public enum LogKind : byte
+3 -2
View File
@@ -17,7 +17,7 @@ public readonly record struct Envelope(FrameFlags Flags, Id16 ItemId, uint Frame
if (data.Length < HeaderSize || data[0] != Version) return false; if (data.Length < HeaderSize || data[0] != Version) return false;
byte flags = data[1]; byte flags = data[1];
if ((flags & 0xF0) != 0) return false; if ((flags & 0xE0) != 0) return false;
uint payloadLen = BinaryPrimitives.ReadUInt32BigEndian(data[22..]); uint payloadLen = BinaryPrimitives.ReadUInt32BigEndian(data[22..]);
if (payloadLen > int.MaxValue || data.Length != HeaderSize + (int)payloadLen) return false; if (payloadLen > int.MaxValue || data.Length != HeaderSize + (int)payloadLen) return false;
@@ -34,9 +34,10 @@ public readonly record struct Envelope(FrameFlags Flags, Id16 ItemId, uint Frame
public bool IsGated => (Flags & FrameFlags.Gated) != 0; public bool IsGated => (Flags & FrameFlags.Gated) != 0;
public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0; public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0;
public bool IsEnc => (Flags & FrameFlags.Enc) != 0; public bool IsEnc => (Flags & FrameFlags.Enc) != 0;
public bool IsControl => (Flags & FrameFlags.Control) != 0;
/// <summary>条目级标志:同一条目的所有帧必须一致。</summary> /// <summary>条目级标志:同一条目的所有帧必须一致。</summary>
public FrameFlags ItemFlags => Flags & (FrameFlags.Snapshot | FrameFlags.Gated); public FrameFlags ItemFlags => Flags & (FrameFlags.Snapshot | FrameFlags.Gated | FrameFlags.Control);
} }
public static class PayloadTiers public static class PayloadTiers
+10
View File
@@ -17,6 +17,10 @@ public enum ErrorCode
RateLimited, RateLimited,
VisitorsFull, VisitorsFull,
Sealed, Sealed,
AccountRequired,
InvitationRequired,
ChannelPasswordRequired,
ChannelPasswordInvalid,
} }
/// <summary>预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。</summary> /// <summary>预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。</summary>
@@ -53,6 +57,10 @@ public static class ErrorCodes
ErrorCode.RateLimited => "rate_limited", ErrorCode.RateLimited => "rate_limited",
ErrorCode.VisitorsFull => "visitors_full", ErrorCode.VisitorsFull => "visitors_full",
ErrorCode.Sealed => "sealed", ErrorCode.Sealed => "sealed",
ErrorCode.AccountRequired => "account_required",
ErrorCode.InvitationRequired => "invitation_required",
ErrorCode.ChannelPasswordRequired => "channel_password_required",
ErrorCode.ChannelPasswordInvalid => "channel_password_invalid",
_ => "bad_request", _ => "bad_request",
}; };
@@ -70,6 +78,8 @@ public static class ErrorCodes
ErrorCode.CaptchaUnavailable => 503, ErrorCode.CaptchaUnavailable => 503,
ErrorCode.RateLimited => 429, ErrorCode.RateLimited => 429,
ErrorCode.VisitorsFull => 403, ErrorCode.VisitorsFull => 403,
ErrorCode.AccountRequired => 401,
ErrorCode.InvitationRequired or ErrorCode.ChannelPasswordRequired or ErrorCode.ChannelPasswordInvalid => 403,
_ => 400, _ => 400,
}; };
} }
+13 -6
View File
@@ -11,7 +11,10 @@ public sealed record Channel(
long ExpiresAt, long ExpiresAt,
long LastSeq, long LastSeq,
long CompactedThrough, long CompactedThrough,
long Bytes); long Bytes,
string CreatorAccount,
byte[]? JoinPasswordHash = null,
byte[]? JoinPasswordSalt = null);
public sealed record Share( public sealed record Share(
Id16 Id, Id16 Id,
@@ -23,7 +26,8 @@ public sealed record Share(
long ExpiresAt, long ExpiresAt,
long? SealedAt, long? SealedAt,
Id16? ManifestItemId, Id16? ManifestItemId,
long Bytes) long Bytes,
string CreatorAccount)
{ {
public bool IsSealed => SealedAt is not null; public bool IsSealed => SealedAt is not null;
} }
@@ -71,14 +75,17 @@ public sealed record Item(
long CreatedAt, long CreatedAt,
long ExpiresAt, long ExpiresAt,
long? CloseDeadline, long? CloseDeadline,
string? Label) string? AuthorAccount)
{ {
public bool IsGated => (Flags & FrameFlags.Gated) != 0; public bool IsGated => (Flags & FrameFlags.Gated) != 0;
public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0; public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0;
public bool IsComplete => FinalIndex is { } f && FrameCount == f + 1; public bool IsComplete => FinalIndex is { } f && FrameCount == f + 1;
} }
public sealed record Seat(byte[] EndpointHash, long JoinedAt, byte[]? Label); public sealed record Seat(byte[] EndpointHash, long JoinedAt, byte[]? Label, string? AccountName = null, string? AccountDeviceId = null);
public sealed record ChannelInvitation(Id16 ChannelId, string Username, string Payload, long CreatedAt);
public sealed record ChannelMember(string Username, string Role, long JoinedAt);
public sealed record ChannelKeyPackage(Id16 ChannelId, string Username, string Role, string Payload, long CreatedAt);
/// <summary>已存储的一帧(完整封装)。Inline 与 BlobKey 二选一。</summary> /// <summary>已存储的一帧(完整封装)。Inline 与 BlobKey 二选一。</summary>
public sealed record StoredFrame( public sealed record StoredFrame(
@@ -94,7 +101,7 @@ public sealed record StoredFrame(
public sealed record LogEntry(long Seq, LogKind Kind, Id16 ItemId, uint FrameIndex, byte Aux, long CreatedAt); public sealed record LogEntry(long Seq, LogKind Kind, Id16 ItemId, uint FrameIndex, byte Aux, long CreatedAt);
/// <summary>一次请求验证通过后的身份。</summary> /// <summary>一次请求验证通过后的身份。</summary>
public sealed record Principal(Role Role, byte[] EndpointHash); public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null);
/// <summary>请求携带的凭据,尚未验证。</summary> /// <summary>请求携带的凭据,尚未验证。</summary>
public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash); public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash, string? AccountName = null, string? AccountDeviceId = null);
+22 -11
View File
@@ -15,22 +15,26 @@ public sealed class ServerLimits
public int InlineThresholdBytes { get; set; } = 64 * 1024; public int InlineThresholdBytes { get; set; } = 64 * 1024;
public int DownloadGraceSeconds { get; set; } = 300; public int DownloadGraceSeconds { get; set; } = 300;
public int TombstoneRetentionSeconds { get; set; } = 604_800; public int TombstoneRetentionSeconds { get; set; } = 604_800;
public int MaxLabelBytes { get; set; } = 255;
public int MaxSeatLabelBytes { get; set; } = 256; public int MaxSeatLabelBytes { get; set; } = 256;
} }
public sealed class ChannelSettings public sealed class ChannelSettings
{ {
[JsonPropertyName("seats")] public int Seats { get; init; } = 2; [JsonPropertyName("join_policy")] public string JoinPolicy { get; set; } = "open";
[JsonPropertyName("members_can_upload")] public bool MembersCanUpload { get; init; } = true; [JsonPropertyName("allow_guests")] public bool AllowGuests { get; set; } = true;
[JsonPropertyName("delete_policy")] public DeletePolicy DeletePolicy { get; init; } = DeletePolicy.Own; [JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; init; } = 86_400; [JsonPropertyName("seats")] public int Seats { get; set; } = 2;
[JsonPropertyName("max_items")] public int MaxItems { get; init; } = 1000; [JsonPropertyName("members_can_upload")] public bool MembersCanUpload { get; set; } = true;
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; init; } = 10; [JsonPropertyName("delete_policy")] public DeletePolicy DeletePolicy { get; set; } = DeletePolicy.Own;
[JsonPropertyName("ui_restrict_copy_download")] public bool UiRestrictCopyDownload { get; init; } [JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; set; } = 86_400;
[JsonPropertyName("max_items")] public int MaxItems { get; set; } = 1000;
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; set; } = 10;
[JsonPropertyName("ui_restrict_copy_download")] public bool UiRestrictCopyDownload { get; set; }
public void Validate(ServerLimits limits) public void Validate(ServerLimits limits)
{ {
if (JoinPolicy is not ("open" or "invite")) throw new WebDropException(ErrorCode.BadRequest);
if (Name is { } name && (name.Length > 48 || name.Any(char.IsControl))) throw new WebDropException(ErrorCode.BadRequest);
if (Seats < 1 || Seats > limits.MaxSeats) throw new WebDropException(ErrorCode.BadRequest); if (Seats < 1 || Seats > limits.MaxSeats) throw new WebDropException(ErrorCode.BadRequest);
if (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds) throw new WebDropException(ErrorCode.BadRequest); if (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds) throw new WebDropException(ErrorCode.BadRequest);
if (MaxItems < 1 || MaxItems > limits.MaxItemsLimit) throw new WebDropException(ErrorCode.BadRequest); if (MaxItems < 1 || MaxItems > limits.MaxItemsLimit) throw new WebDropException(ErrorCode.BadRequest);
@@ -40,6 +44,10 @@ public sealed class ChannelSettings
public sealed class ChannelSettingsPatch public sealed class ChannelSettingsPatch
{ {
[JsonPropertyName("join_policy")] public string? JoinPolicy { get; init; }
[JsonPropertyName("allow_guests")] public bool? AllowGuests { get; init; }
[JsonPropertyName("join_password")] public string? JoinPassword { get; init; }
[JsonPropertyName("name")] public string? Name { get; init; }
[JsonPropertyName("seats")] public int? Seats { get; init; } [JsonPropertyName("seats")] public int? Seats { get; init; }
[JsonPropertyName("members_can_upload")] public bool? MembersCanUpload { get; init; } [JsonPropertyName("members_can_upload")] public bool? MembersCanUpload { get; init; }
[JsonPropertyName("delete_policy")] public DeletePolicy? DeletePolicy { get; init; } [JsonPropertyName("delete_policy")] public DeletePolicy? DeletePolicy { get; init; }
@@ -50,6 +58,9 @@ public sealed class ChannelSettingsPatch
public ChannelSettings ApplyTo(ChannelSettings s) => new() public ChannelSettings ApplyTo(ChannelSettings s) => new()
{ {
JoinPolicy = JoinPolicy ?? s.JoinPolicy,
AllowGuests = AllowGuests ?? s.AllowGuests,
Name = Name is null ? s.Name : string.IsNullOrWhiteSpace(Name) ? null : Name.Trim(),
Seats = Seats ?? s.Seats, Seats = Seats ?? s.Seats,
MembersCanUpload = MembersCanUpload ?? s.MembersCanUpload, MembersCanUpload = MembersCanUpload ?? s.MembersCanUpload,
DeletePolicy = DeletePolicy ?? s.DeletePolicy, DeletePolicy = DeletePolicy ?? s.DeletePolicy,
@@ -62,9 +73,9 @@ public sealed class ChannelSettingsPatch
public sealed class ShareSettings public sealed class ShareSettings
{ {
[JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; init; } = 86_400; [JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; set; } = 86_400;
[JsonPropertyName("max_visitors")] public int MaxVisitors { get; init; } [JsonPropertyName("max_visitors")] public int MaxVisitors { get; set; }
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; init; } = 10; [JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; set; } = 10;
public void Validate(ServerLimits limits) public void Validate(ServerLimits limits)
{ {
+12 -6
View File
@@ -10,8 +10,9 @@ public sealed class ShareService(
ServerLimits limits, ServerLimits limits,
int draftTtlSeconds) int draftTtlSeconds)
{ {
public ShareInfo Create(CreateShareRequest req, byte[]? creatorEndpointHash) public ShareInfo Create(CreateShareRequest req, byte[]? creatorEndpointHash, string creatorAccount)
{ {
if (string.IsNullOrWhiteSpace(creatorAccount)) throw new WebDropException(ErrorCode.AccountRequired);
if (!Id16.TryParse(req.Id, out var id) if (!Id16.TryParse(req.Id, out var id)
|| !Codec.TryDecodeExact(req.ReadHash, TokenHash.Size, out var readHash) || !Codec.TryDecodeExact(req.ReadHash, TokenHash.Size, out var readHash)
|| !Codec.TryDecodeExact(req.OwnerHash, TokenHash.Size, out var ownerHash)) || !Codec.TryDecodeExact(req.OwnerHash, TokenHash.Size, out var ownerHash))
@@ -21,7 +22,7 @@ public sealed class ShareService(
settings.Validate(limits); settings.Validate(limits);
long now = clock.NowMs; long now = clock.NowMs;
var share = new Share(id, settings, readHash, ownerHash, creatorEndpointHash, now, var share = new Share(id, settings, readHash, ownerHash, creatorEndpointHash, now,
now + Math.Max(60, draftTtlSeconds) * 1000L, null, null, 0); now + Math.Max(60, draftTtlSeconds) * 1000L, null, null, 0, creatorAccount);
store.Write(tx => store.Write(tx =>
{ {
@@ -48,6 +49,7 @@ public sealed class ShareService(
return ToInfo(share, tx.ListShareItems(id)); return ToInfo(share, tx.ListShareItems(id));
}); });
public ShareInfo UpdateSettings(Id16 id, Credentials creds, ShareSettingsPatch patch) => store.Write(tx => public ShareInfo UpdateSettings(Id16 id, Credentials creds, ShareSettingsPatch patch) => store.Write(tx =>
{ {
var share = Draft(tx.GetShare(id), creds); var share = Draft(tx.GetShare(id), creds);
@@ -66,7 +68,7 @@ public sealed class ShareService(
throw new WebDropException(ErrorCode.TooLarge); throw new WebDropException(ErrorCode.TooLarge);
if (!env.IsEnc) throw new WebDropException(ErrorCode.ModeMismatch); if (!env.IsEnc) throw new WebDropException(ErrorCode.ModeMismatch);
if (!PayloadTiers.IsValidEncPayloadLength(env.PayloadLength)) throw new WebDropException(ErrorCode.BadRequest); if (!PayloadTiers.IsValidEncPayloadLength(env.PayloadLength)) throw new WebDropException(ErrorCode.BadRequest);
if (env.IsSnapshot) throw new WebDropException(ErrorCode.BadRequest); if (env.IsSnapshot || env.IsControl) throw new WebDropException(ErrorCode.BadRequest);
if (!env.IsGated && (env.FrameIndex != 0 || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest); if (!env.IsGated && (env.FrameIndex != 0 || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest);
if (maxDownloads is < 0) throw new WebDropException(ErrorCode.BadRequest); if (maxDownloads is < 0) throw new WebDropException(ErrorCode.BadRequest);
@@ -198,7 +200,7 @@ public sealed class ShareService(
var frame = store.Write(tx => var frame = store.Write(tx =>
{ {
var share = SealedForRead(tx.GetShare(id), creds); var share = SealedForRead(tx.GetShare(id), creds);
RegisterVisitor(tx, share, creds.EndpointHash); RegisterVisitor(tx, share, creds.EndpointHash, creds.AccountName);
var manifestId = share.ManifestItemId ?? throw new WebDropException(ErrorCode.NotFound); var manifestId = share.ManifestItemId ?? throw new WebDropException(ErrorCode.NotFound);
return tx.GetShareFrame(id, manifestId, 0) ?? throw new WebDropException(ErrorCode.Gone); return tx.GetShareFrame(id, manifestId, 0) ?? throw new WebDropException(ErrorCode.Gone);
}); });
@@ -217,7 +219,7 @@ public sealed class ShareService(
long now = clock.NowMs; long now = clock.NowMs;
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline)) if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline))
throw new WebDropException(ErrorCode.Gone); throw new WebDropException(ErrorCode.Gone);
RegisterVisitor(tx, share, creds.EndpointHash); RegisterVisitor(tx, share, creds.EndpointHash, creds.AccountName);
if (fromIndex == 0) if (fromIndex == 0)
{ {
if (item.MaxDownloads > 0 && item.Downloads >= item.MaxDownloads) throw new WebDropException(ErrorCode.Gone); if (item.MaxDownloads > 0 && item.Downloads >= item.MaxDownloads) throw new WebDropException(ErrorCode.Gone);
@@ -310,6 +312,8 @@ public sealed class ShareService(
private void RequireOwner(Share share, Credentials creds) private void RequireOwner(Share share, Credentials creds)
{ {
if (share.CreatorAccount != creds.AccountName)
throw new WebDropException(ErrorCode.AccountRequired);
if (creds.ClaimedRole != Role.Owner || !TokenHash.Matches(creds.Token ?? [], share.OwnerHash)) if (creds.ClaimedRole != Role.Owner || !TokenHash.Matches(creds.Token ?? [], share.OwnerHash))
throw new WebDropException(ErrorCode.Unauthorized); throw new WebDropException(ErrorCode.Unauthorized);
} }
@@ -320,8 +324,9 @@ public sealed class ShareService(
return candidate; return candidate;
} }
private void RegisterVisitor(IResourceTx tx, Share share, byte[] endpointHash) private void RegisterVisitor(IResourceTx tx, Share share, byte[] endpointHash, string? accountName)
{ {
if (accountName == share.CreatorAccount) return;
if (share.CreatorEndpointHash is { } creator && CryptographicOperations.FixedTimeEquals(creator, endpointHash)) return; if (share.CreatorEndpointHash is { } creator && CryptographicOperations.FixedTimeEquals(creator, endpointHash)) return;
if (tx.HasShareVisitor(share.Id, endpointHash)) return; if (tx.HasShareVisitor(share.Id, endpointHash)) return;
if (share.Settings.MaxVisitors > 0 && tx.CountShareVisitors(share.Id) >= share.Settings.MaxVisitors) if (share.Settings.MaxVisitors > 0 && tx.CountShareVisitors(share.Id) >= share.Settings.MaxVisitors)
@@ -360,6 +365,7 @@ public sealed class ShareService(
ItemId = i.ItemId.ToBase64Url(), ItemId = i.ItemId.ToBase64Url(),
Bytes = i.Bytes, Bytes = i.Bytes,
Frames = i.FrameCount, Frames = i.FrameCount,
Complete = i.IsComplete,
MaxDownloads = i.MaxDownloads, MaxDownloads = i.MaxDownloads,
DownloadsLeft = i.MaxDownloads == 0 ? null : Math.Max(0, i.MaxDownloads - i.Downloads), DownloadsLeft = i.MaxDownloads == 0 ? null : Math.Max(0, i.MaxDownloads - i.Downloads),
}).ToList(), }).ToList(),
@@ -0,0 +1,140 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
namespace WebDrop.Frontend;
/// <summary>在同源模式下,从本程序集资源提供 Vite 前端。</summary>
public static class EmbeddedFrontend
{
private const string ResourcePrefix = "WebDrop.Frontend.Assets/";
public static void Use(IApplicationBuilder app, string? extraConnectOrigin = null)
{
var assembly = typeof(EmbeddedFrontend).Assembly;
var resources = assembly.GetManifestResourceNames()
.Where(name => name.StartsWith(ResourcePrefix, StringComparison.Ordinal))
.ToDictionary(
name => name[ResourcePrefix.Length..].Replace('\\', '/'),
StringComparer.Ordinal);
if (!resources.ContainsKey("index.html")) return;
var contentSecurityPolicy = BuildContentSecurityPolicy(extraConnectOrigin);
app.Use((HttpContext context, RequestDelegate next) =>
HandleAsync(context, next, assembly, resources, contentSecurityPolicy));
}
private static async Task HandleAsync(
HttpContext context,
RequestDelegate next,
System.Reflection.Assembly assembly,
IReadOnlyDictionary<string, string> resources,
string contentSecurityPolicy)
{
if (!(HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)))
{
await next(context);
return;
}
var normalized = NormalizePath(context.Request.Path.Value);
if (normalized is null)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
if (IsApiPath(normalized))
{
await next(context);
return;
}
if (resources.TryGetValue(normalized, out var resourceName))
{
await SendAsync(context, assembly, resources, normalized, contentSecurityPolicy, resourceName);
return;
}
// A missing Vite asset must stay a 404. Other unknown paths are client-side routes.
if (normalized.StartsWith("assets/", StringComparison.Ordinal) || normalized.Equals("favicon.ico", StringComparison.OrdinalIgnoreCase))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
await SendAsync(context, assembly, resources, "index.html", contentSecurityPolicy);
}
private static async Task SendAsync(
HttpContext context,
System.Reflection.Assembly assembly,
IReadOnlyDictionary<string, string> resources,
string path,
string contentSecurityPolicy,
string? resourceName = null)
{
resourceName ??= resources[path];
await using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = ContentType(path);
context.Response.Headers["Cache-Control"] = path.StartsWith("assets/", StringComparison.Ordinal)
? "public, max-age=31536000, immutable"
: "no-store";
// Dedicated workers enforce the CSP returned with their own script response.
// Give bundled JS the same app policy so the crypto worker can call the same-origin API.
if (path.Equals("index.html", StringComparison.Ordinal) || path.EndsWith(".js", StringComparison.OrdinalIgnoreCase))
context.Response.Headers["Content-Security-Policy"] = contentSecurityPolicy;
if (!HttpMethods.IsHead(context.Request.Method))
await stream.CopyToAsync(context.Response.Body, context.RequestAborted);
}
private static string? NormalizePath(string? path)
{
if (string.IsNullOrEmpty(path)) return "index.html";
var normalized = path.Replace('\\', '/').Trim('/');
if (normalized.Length == 0) return "index.html";
if (normalized.Contains('\0') || normalized.Split('/').Any(segment => segment is ".." or "." or ""))
return null;
return normalized;
}
private static bool IsApiPath(string path) =>
path.Equals("api", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("api/", StringComparison.OrdinalIgnoreCase);
private static string ContentType(string path) => Path.GetExtension(path).ToLowerInvariant() switch
{
".html" => "text/html; charset=utf-8",
".js" => "text/javascript; charset=utf-8",
".css" => "text/css; charset=utf-8",
".json" => "application/json; charset=utf-8",
".svg" => "image/svg+xml",
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
".ico" => "image/x-icon",
".woff2" => "font/woff2",
".wasm" => "application/wasm",
_ => "application/octet-stream",
};
private static string BuildContentSecurityPolicy(string? extraConnectOrigin)
{
var connectSources = new List<string> { "'self'", "ws:", "wss:" };
if (Uri.TryCreate(extraConnectOrigin, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https")
connectSources.Add(uri.GetLeftPart(UriPartial.Authority));
return "default-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; " +
"form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; worker-src 'self' blob:; " +
$"connect-src {string.Join(' ', connectSources)}";
}
}
+22
View File
@@ -0,0 +1,22 @@
# WebDrop.Frontend
独立的 ASP.NET Core 模块,负责提供嵌入程序集的 Vite 前端资源。
## 构建
如果 `web/dist/index.html` 存在,项目会将 `web/dist` 作为 embedded resources 编入 `WebDrop.Frontend.dll`。也可通过 MSBuild 属性指定资源目录:
```powershell
dotnet publish ..\WebDrop.Api\WebDrop.Api.csproj -p:WebDropFrontendDist=C:\build\web-dist
```
Containerfile 会构建 `web/`,再把 dist 传给 .NET 发布阶段。`EmbeddedFrontend.Map` 只映射页面与静态文件,不依赖 `WebDrop.Core` 或存储层。
开发时可以不构建 dist,此时模块不映射页面路由;使用 `npm run dev` 配合 Vite API 代理。
## 路由与响应
- `/` 和未知的前端路由返回 `index.html`,支持 React SPA 深层链接。
- `/assets/*` 从程序集资源读取;缺失资源和 `/api/*` 返回 404。
- 页面使用同源 CSP,并允许配置的 Cap API origin、WebSocket 与 Worker。
- `index.html` 不缓存;Vite `assets/` 使用 immutable 缓存。
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>WebDrop.Frontend</RootNamespace>
<AssemblyName>WebDrop.Frontend</AssemblyName>
<WebDropFrontendDist Condition="'$(WebDropFrontendDist)' == ''">$(MSBuildProjectDirectory)/../../../web/dist</WebDropFrontendDist>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup Condition="Exists('$(WebDropFrontendDist)/index.html')">
<EmbeddedResource Include="$(WebDropFrontendDist)/**/*">
<LogicalName>WebDrop.Frontend.Assets/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
</EmbeddedResource>
</ItemGroup>
</Project>
@@ -0,0 +1,236 @@
using System.Security.Cryptography;
using Microsoft.Data.Sqlite;
namespace WebDrop.Storage;
/// <summary>Account authentication and opaque client-encrypted vault storage.</summary>
public sealed class SqliteAccountStore : IDisposable
{
private readonly SqliteConnection _connection;
private readonly object _gate = new();
public SqliteAccountStore(string path)
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
_connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = true }.ToString());
_connection.Open();
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS accounts (username TEXT PRIMARY KEY COLLATE NOCASE, salt BLOB NOT NULL, verifier BLOB NOT NULL, verifier_salt BLOB NOT NULL, public_key TEXT NOT NULL, vault BLOB NOT NULL, revision INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS account_sessions (token_hash BLOB PRIMARY KEY, username TEXT NOT NULL, expires INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS account_devices (username TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, last_seen INTEGER NOT NULL, PRIMARY KEY(username,id));
CREATE TABLE IF NOT EXISTS account_device_sessions (token_hash BLOB PRIMARY KEY, username TEXT NOT NULL, device_id TEXT NOT NULL);
""";
cmd.ExecuteNonQuery();
}
private static byte[] HashVerifier(byte[] verifier, byte[] salt) => Rfc2898DeriveBytes.Pbkdf2(verifier, salt, 210_000, HashAlgorithmName.SHA256, 32);
public byte[] LoginSalt(string username)
{
lock (_gate)
{
using (var cleanup = _connection.CreateCommand())
{
cleanup.CommandText = "DELETE FROM account_sessions WHERE expires<=$n; DELETE FROM account_device_sessions WHERE token_hash NOT IN (SELECT token_hash FROM account_sessions);";
cleanup.Parameters.AddWithValue("$n", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
cleanup.ExecuteNonQuery();
}
using var q = _connection.CreateCommand();
q.CommandText = "SELECT salt FROM accounts WHERE username=$u";
q.Parameters.AddWithValue("$u", username);
var value = q.ExecuteScalar();
byte[] salt = value is byte[] found ? found : RandomNumberGenerator.GetBytes(16);
return salt;
}
}
public bool Register(string username, byte[] salt, byte[] verifier, byte[] vault, string publicKey)
{
lock (_gate)
{
try
{
using var cmd = _connection.CreateCommand();
var verifierSalt = RandomNumberGenerator.GetBytes(16);
cmd.CommandText = "INSERT INTO accounts(username,salt,verifier,vault,revision,public_key,verifier_salt) VALUES($u,$s,$v,$b,1,$p,$vs)";
cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$s", salt);
cmd.Parameters.AddWithValue("$v", HashVerifier(verifier, verifierSalt)); cmd.Parameters.AddWithValue("$b", vault);
cmd.Parameters.AddWithValue("$p", publicKey); cmd.Parameters.AddWithValue("$vs", verifierSalt);
cmd.ExecuteNonQuery(); return true;
}
catch (SqliteException e) when (e.SqliteErrorCode == 19) { return false; }
}
}
public string? Authenticate(string username, byte[] suppliedVerifier, string? deviceId = null, string? deviceName = null)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT verifier,verifier_salt FROM accounts WHERE username=$u";
cmd.Parameters.AddWithValue("$u", username);
using var r = cmd.ExecuteReader();
bool found = r.Read();
var verifier = found ? (byte[])r[0] : new byte[32];
var verifierSalt = found ? (byte[])r[1] : new byte[16];
r.Close();
var expected = HashVerifier(suppliedVerifier, verifierSalt);
if (!CryptographicOperations.FixedTimeEquals(verifier, expected) || !found) return null;
if (deviceId is not null && (!Guid.TryParseExact(deviceId, "D", out _) || deviceName is null || deviceName.Length is < 1 or > 80 || deviceName.Any(char.IsControl))) return null;
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
using var transaction = _connection.BeginTransaction();
using var add = _connection.CreateCommand();
add.Transaction = transaction;
add.CommandText = "INSERT INTO account_sessions(token_hash,username,expires) VALUES($h,$u,$e)";
add.Parameters.AddWithValue("$h", SHA256.HashData(Convert.FromBase64String(token)));
add.Parameters.AddWithValue("$u", username); add.Parameters.AddWithValue("$e", DateTimeOffset.UtcNow.AddDays(30).ToUnixTimeSeconds());
add.ExecuteNonQuery();
if (deviceId is not null)
{
using var device = _connection.CreateCommand();
device.Transaction = transaction;
device.CommandText = """
INSERT INTO account_devices(username,id,name,created_at,last_seen) VALUES($u,$d,$name,$now,$now)
ON CONFLICT(username,id) DO UPDATE SET name=excluded.name,last_seen=excluded.last_seen;
INSERT INTO account_device_sessions(token_hash,username,device_id) VALUES($h,$u,$d);
""";
device.Parameters.AddWithValue("$u", username); device.Parameters.AddWithValue("$d", deviceId);
device.Parameters.AddWithValue("$name", deviceName!); device.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
device.Parameters.AddWithValue("$h", SHA256.HashData(Convert.FromBase64String(token)));
device.ExecuteNonQuery();
}
transaction.Commit();
return token;
}
}
public bool HasSession(string token, out string username)
{
username = "";
if (token.Length != 44) return false;
byte[] raw;
try { raw = Convert.FromBase64String(token); } catch (FormatException) { return false; }
if (raw.Length != 32) return false;
lock (_gate)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT username FROM account_sessions WHERE token_hash=$h AND expires>$n";
cmd.Parameters.AddWithValue("$h", SHA256.HashData(raw)); cmd.Parameters.AddWithValue("$n", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
var found = cmd.ExecuteScalar(); if (found is not string name) return false;
username = name;
using var seen = _connection.CreateCommand();
seen.CommandText = """
UPDATE account_devices SET last_seen=$now WHERE username=$u AND last_seen<$cutoff
AND id=(SELECT device_id FROM account_device_sessions WHERE token_hash=$h)
""";
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
seen.Parameters.AddWithValue("$now", now); seen.Parameters.AddWithValue("$cutoff", now - 60_000);
seen.Parameters.AddWithValue("$u", name); seen.Parameters.AddWithValue("$h", SHA256.HashData(raw)); seen.ExecuteNonQuery();
return true;
}
}
public void RevokeSession(string token)
{
if (token.Length != 44) return;
byte[] raw;
try { raw = Convert.FromBase64String(token); } catch (FormatException) { return; }
lock (_gate)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM account_sessions WHERE token_hash=$h; DELETE FROM account_device_sessions WHERE token_hash=$h;";
cmd.Parameters.AddWithValue("$h", SHA256.HashData(raw));
cmd.ExecuteNonQuery();
}
}
public (byte[] Vault, long Revision)? GetVault(string username)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT vault,revision FROM accounts WHERE username=$u"; cmd.Parameters.AddWithValue("$u", username);
using var r = cmd.ExecuteReader(); return r.Read() ? ((byte[])r[0], r.GetInt64(1)) : null;
}
}
public long? PutVault(string username, byte[] vault, long revision)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "UPDATE accounts SET vault=$v,revision=revision+1 WHERE username=$u AND revision=$r RETURNING revision";
cmd.Parameters.AddWithValue("$v", vault); cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$r", revision);
var updated = cmd.ExecuteScalar(); return updated is long value ? value : null;
}
}
public void Dispose() { _connection.Dispose(); SqliteConnection.ClearPool(_connection); }
public string? SessionDevice(string token)
{
if (!HasSession(token, out _)) return null;
lock (_gate)
{
using var q = _connection.CreateCommand();
q.CommandText = "SELECT device_id FROM account_device_sessions WHERE token_hash=$h";
q.Parameters.AddWithValue("$h", SHA256.HashData(Convert.FromBase64String(token)));
return q.ExecuteScalar() as string;
}
}
public IReadOnlyList<AccountDevice> Devices(string username, string? currentId)
{
lock (_gate)
{
using var q = _connection.CreateCommand();
q.CommandText = """
SELECT d.id,d.name,d.created_at,d.last_seen FROM account_devices d
WHERE d.username=$u AND EXISTS (SELECT 1 FROM account_device_sessions ds JOIN account_sessions s ON s.token_hash=ds.token_hash
WHERE ds.username=d.username AND ds.device_id=d.id AND s.expires>$now)
ORDER BY d.last_seen DESC
""";
q.Parameters.AddWithValue("$u", username); q.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
using var r = q.ExecuteReader(); var result = new List<AccountDevice>();
while (r.Read()) result.Add(new(r.GetString(0), r.GetString(1), r.GetInt64(2), r.GetInt64(3), r.GetString(0) == currentId));
return result;
}
}
public bool RenameDevice(string username, string id, string name)
{
if (name.Length is < 1 or > 80 || name.Any(char.IsControl)) return false;
lock (_gate)
{
using var q = _connection.CreateCommand(); q.CommandText = "UPDATE account_devices SET name=$n WHERE username=$u AND id=$d";
q.Parameters.AddWithValue("$n", name); q.Parameters.AddWithValue("$u", username); q.Parameters.AddWithValue("$d", id);
return q.ExecuteNonQuery() > 0;
}
}
public void RevokeDevice(string username, string id)
{
lock (_gate)
{
using var q = _connection.CreateCommand();
q.CommandText = """
DELETE FROM account_sessions WHERE token_hash IN (SELECT token_hash FROM account_device_sessions WHERE username=$u AND device_id=$d);
DELETE FROM account_device_sessions WHERE username=$u AND device_id=$d;
DELETE FROM account_devices WHERE username=$u AND id=$d;
""";
q.Parameters.AddWithValue("$u", username); q.Parameters.AddWithValue("$d", id); q.ExecuteNonQuery();
}
}
public string? GetPublicKey(string username)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT public_key FROM accounts WHERE username=$u";
cmd.Parameters.AddWithValue("$u", username); return cmd.ExecuteScalar() as string;
}
}
}
public sealed record AccountDevice(string Id, string Name, long CreatedAt, long LastSeen, bool Current);
+100 -40
View File
@@ -10,7 +10,7 @@ namespace WebDrop.Storage;
/// </summary> /// </summary>
public sealed class SqliteChannelStore : IResourceStore, IDisposable public sealed class SqliteChannelStore : IResourceStore, IDisposable
{ {
private const int SchemaVersion = 3; private const int SchemaVersion = 1;
private readonly string _connectionString; private readonly string _connectionString;
private readonly SqliteConnection _writeConn; private readonly SqliteConnection _writeConn;
@@ -31,7 +31,20 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
_writeConn = Open(); _writeConn = Open();
Exec(_writeConn, "PRAGMA journal_mode=WAL;"); Exec(_writeConn, "PRAGMA journal_mode=WAL;");
Migrate(); InitializeSchema();
using (var columns = _writeConn.CreateCommand())
{
columns.CommandText = "SELECT COUNT(*) FROM pragma_table_info('seats') WHERE name='account_device_id'";
if ((long)columns.ExecuteScalar()! == 0) Exec(_writeConn, "ALTER TABLE seats ADD COLUMN account_device_id TEXT;");
}
// Additive table: retain the running v2 database and its existing channel data.
Exec(_writeConn, """
CREATE TABLE IF NOT EXISTS channel_key_packages (
channel_id BLOB NOT NULL, username TEXT NOT NULL, payload TEXT NOT NULL,
created_at INTEGER NOT NULL, PRIMARY KEY (channel_id, username)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS ix_channel_keys_username ON channel_key_packages(username);
""");
} }
public T Read<T>(Func<IResourceTx, T> action) public T Read<T>(Func<IResourceTx, T> action)
@@ -65,7 +78,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
public void Dispose() public void Dispose()
{ {
_writeConn.Dispose(); _writeConn.Dispose();
SqliteConnection.ClearAllPools(); SqliteConnection.ClearPool(_writeConn);
} }
private SqliteConnection Open() private SqliteConnection Open()
@@ -83,23 +96,19 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
private void Migrate() private void InitializeSchema()
{ {
using var cmd = _writeConn.CreateCommand(); using var cmd = _writeConn.CreateCommand();
cmd.CommandText = "PRAGMA user_version;"; cmd.CommandText = "PRAGMA user_version;";
long version = (long)cmd.ExecuteScalar()!; long version = (long)cmd.ExecuteScalar()!;
if (version > SchemaVersion) throw new InvalidOperationException("数据库版本高于本程序支持的版本");
if (version == SchemaVersion) return; if (version == SchemaVersion) return;
if (version != 0) throw new InvalidOperationException("存储版本不兼容当前应用契约");
using var tx = _writeConn.BeginTransaction(); using var tx = _writeConn.BeginTransaction();
if (version == 0) Exec(_writeConn, SchemaV1); Exec(_writeConn, Schema);
if (version < 2) Exec(_writeConn, SchemaV2);
if (version < 3) Exec(_writeConn, SchemaV3);
Exec(_writeConn, $"PRAGMA user_version={SchemaVersion};"); Exec(_writeConn, $"PRAGMA user_version={SchemaVersion};");
tx.Commit(); tx.Commit();
} }
private const string Schema = """
private const string SchemaV1 = """
CREATE TABLE channels ( CREATE TABLE channels (
id BLOB NOT NULL PRIMARY KEY, id BLOB NOT NULL PRIMARY KEY,
mode INTEGER NOT NULL, mode INTEGER NOT NULL,
@@ -111,7 +120,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
last_seq INTEGER NOT NULL DEFAULT 0, last_seq INTEGER NOT NULL DEFAULT 0,
compacted_through INTEGER NOT NULL DEFAULT 0, compacted_through INTEGER NOT NULL DEFAULT 0,
bytes INTEGER NOT NULL DEFAULT 0 bytes INTEGER NOT NULL DEFAULT 0,
creator_account TEXT NOT NULL,
join_password_hash BLOB,
join_password_salt BLOB
); );
CREATE INDEX ix_channels_expires ON channels(expires_at); CREATE INDEX ix_channels_expires ON channels(expires_at);
@@ -119,6 +131,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
channel_id BLOB NOT NULL, channel_id BLOB NOT NULL,
endpoint_hash BLOB NOT NULL, endpoint_hash BLOB NOT NULL,
label BLOB, label BLOB,
account_name TEXT,
joined_at INTEGER NOT NULL, joined_at INTEGER NOT NULL,
PRIMARY KEY (channel_id, endpoint_hash) PRIMARY KEY (channel_id, endpoint_hash)
) WITHOUT ROWID; ) WITHOUT ROWID;
@@ -127,6 +140,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
channel_id BLOB NOT NULL, channel_id BLOB NOT NULL,
item_id BLOB NOT NULL, item_id BLOB NOT NULL,
author_hash BLOB NOT NULL, author_hash BLOB NOT NULL,
author_account TEXT,
flags INTEGER NOT NULL, flags INTEGER NOT NULL,
max_downloads INTEGER NOT NULL, max_downloads INTEGER NOT NULL,
downloads INTEGER NOT NULL, downloads INTEGER NOT NULL,
@@ -137,7 +151,6 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
close_deadline INTEGER, close_deadline INTEGER,
label TEXT,
PRIMARY KEY (channel_id, item_id) PRIMARY KEY (channel_id, item_id)
); );
CREATE INDEX ix_items_channel_created ON items(channel_id, created_at); CREATE INDEX ix_items_channel_created ON items(channel_id, created_at);
@@ -168,9 +181,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
PRIMARY KEY (channel_id, seq) PRIMARY KEY (channel_id, seq)
) WITHOUT ROWID; ) WITHOUT ROWID;
CREATE INDEX ix_log_item ON log(channel_id, item_id); CREATE INDEX ix_log_item ON log(channel_id, item_id);
""";
private const string SchemaV2 = """
CREATE TABLE shares ( CREATE TABLE shares (
id BLOB NOT NULL PRIMARY KEY, id BLOB NOT NULL PRIMARY KEY,
settings TEXT NOT NULL, settings TEXT NOT NULL,
@@ -181,7 +192,8 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
sealed_at INTEGER, sealed_at INTEGER,
manifest_item_id BLOB, manifest_item_id BLOB,
bytes INTEGER NOT NULL DEFAULT 0 bytes INTEGER NOT NULL DEFAULT 0,
creator_account TEXT NOT NULL
); );
CREATE INDEX ix_shares_expires ON shares(expires_at); CREATE INDEX ix_shares_expires ON shares(expires_at);
@@ -222,9 +234,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
PRIMARY KEY (share_id, item_id, frame_index) PRIMARY KEY (share_id, item_id, frame_index)
) WITHOUT ROWID; ) WITHOUT ROWID;
CREATE INDEX ix_share_frames_blob ON share_frames(blob_key) WHERE blob_key IS NOT NULL; 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 ( CREATE TABLE download_grants (
resource_id BLOB NOT NULL, resource_id BLOB NOT NULL,
item_id BLOB NOT NULL, item_id BLOB NOT NULL,
@@ -232,12 +242,18 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
PRIMARY KEY (resource_id, item_id, endpoint_hash) PRIMARY KEY (resource_id, item_id, endpoint_hash)
) WITHOUT ROWID; ) WITHOUT ROWID;
CREATE INDEX ix_download_grants_resource ON download_grants(resource_id); CREATE INDEX ix_download_grants_resource ON download_grants(resource_id);
CREATE TABLE channel_invitations(channel_id BLOB NOT NULL,username TEXT NOT NULL,payload TEXT NOT NULL,created_at INTEGER NOT NULL,PRIMARY KEY(channel_id,username));
CREATE INDEX ix_channel_invitations_username ON channel_invitations(username);
CREATE TABLE channel_join_denials(channel_id BLOB NOT NULL,target TEXT NOT NULL,PRIMARY KEY(channel_id,target));
CREATE TABLE channel_members(channel_id BLOB NOT NULL,username TEXT NOT NULL,role TEXT NOT NULL,joined_at INTEGER NOT NULL,PRIMARY KEY(channel_id,username));
"""; """;
private sealed class SqliteTx(SqliteConnection conn) : IResourceTx 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"; private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes, creator_account, join_password_hash, join_password_salt";
private const string ItemCols = "channel_id, item_id, author_hash, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline, label"; private const string ItemCols = "channel_id, item_id, author_hash, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline, author_account";
private const string FrameCols = "channel_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key"; private const string FrameCols = "channel_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key";
private const string ShareItemCols = "share_id, item_id, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline"; private const string ShareItemCols = "share_id, item_id, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline";
private const string ShareFrameCols = "share_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key"; private const string ShareFrameCols = "share_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key";
@@ -291,10 +307,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
IdAt(r, 0), IdAt(r, 0),
JsonSerializer.Deserialize(r.GetString(1), CoreJsonContext.Default.ShareSettings)!, JsonSerializer.Deserialize(r.GetString(1), CoreJsonContext.Default.ShareSettings)!,
Blob(r, 2), BlobOrNull(r, 3), BlobOrNull(r, 4), r.GetInt64(5), r.GetInt64(6), Blob(r, 2), BlobOrNull(r, 3), BlobOrNull(r, 4), r.GetInt64(5), r.GetInt64(6),
LongOrNull(r, 7), r.IsDBNull(8) ? null : IdAt(r, 8), r.GetInt64(9)); LongOrNull(r, 7), r.IsDBNull(8) ? null : IdAt(r, 8), r.GetInt64(9), r.GetString(10));
public Share? GetShare(Id16 id) => One( public Share? GetShare(Id16 id) => One(
"SELECT id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes FROM shares WHERE id=@id", "SELECT id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes,creator_account FROM shares WHERE id=@id",
MapShare, ("@id", id.ToArray())); MapShare, ("@id", id.ToArray()));
public bool HasResourceId(Id16 id) => Scalar( public bool HasResourceId(Id16 id) => Scalar(
@@ -302,17 +318,17 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
("@id", id.ToArray())) is not null; ("@id", id.ToArray())) is not null;
public void InsertShare(Share s) => Run( public void InsertShare(Share s) => Run(
"INSERT INTO shares (id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes) VALUES (@id,@settings,@read,@owner,@creator,@created,@expires,@sealed,@manifest,@bytes)", "INSERT INTO shares (id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes,creator_account) VALUES (@id,@settings,@read,@owner,@creator,@created,@expires,@sealed,@manifest,@bytes,@account)",
("@id", s.Id.ToArray()), ("@settings", JsonSerializer.Serialize(s.Settings, CoreJsonContext.Default.ShareSettings)), ("@id", s.Id.ToArray()), ("@settings", JsonSerializer.Serialize(s.Settings, CoreJsonContext.Default.ShareSettings)),
("@read", s.ReadHash), ("@owner", s.OwnerHash), ("@creator", s.CreatorEndpointHash), ("@read", s.ReadHash), ("@owner", s.OwnerHash), ("@creator", s.CreatorEndpointHash),
("@created", s.CreatedAt), ("@expires", s.ExpiresAt), ("@sealed", s.SealedAt), ("@created", s.CreatedAt), ("@expires", s.ExpiresAt), ("@sealed", s.SealedAt),
("@manifest", s.ManifestItemId?.ToArray()), ("@bytes", s.Bytes)); ("@manifest", s.ManifestItemId?.ToArray()), ("@bytes", s.Bytes), ("@account", s.CreatorAccount));
public void UpdateShare(Share s) => Run( public void UpdateShare(Share s) => Run(
"UPDATE shares SET settings=@settings,owner_hash=@owner,expires_at=@expires,sealed_at=@sealed,manifest_item_id=@manifest,bytes=@bytes WHERE id=@id", "UPDATE shares SET settings=@settings,owner_hash=@owner,expires_at=@expires,sealed_at=@sealed,manifest_item_id=@manifest,bytes=@bytes,creator_account=@account WHERE id=@id",
("@settings", JsonSerializer.Serialize(s.Settings, CoreJsonContext.Default.ShareSettings)), ("@owner", s.OwnerHash), ("@settings", JsonSerializer.Serialize(s.Settings, CoreJsonContext.Default.ShareSettings)), ("@owner", s.OwnerHash),
("@expires", s.ExpiresAt), ("@sealed", s.SealedAt), ("@manifest", s.ManifestItemId?.ToArray()), ("@expires", s.ExpiresAt), ("@sealed", s.SealedAt), ("@manifest", s.ManifestItemId?.ToArray()),
("@bytes", s.Bytes), ("@id", s.Id.ToArray())); ("@bytes", s.Bytes), ("@id", s.Id.ToArray()), ("@account", s.CreatorAccount));
public IReadOnlyList<Id16> ListExpiredShares(long now, int limit) => Many( public IReadOnlyList<Id16> ListExpiredShares(long now, int limit) => Many(
"SELECT id FROM shares WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit)); "SELECT id FROM shares WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit));
@@ -400,18 +416,18 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
BlobOrNull(r, 3), BlobOrNull(r, 3),
BlobOrNull(r, 4), BlobOrNull(r, 4),
Blob(r, 5), Blob(r, 5),
r.GetInt64(6), r.GetInt64(7), r.GetInt64(8), r.GetInt64(9), r.GetInt64(10)); r.GetInt64(6), r.GetInt64(7), r.GetInt64(8), r.GetInt64(9), r.GetInt64(10), r.GetString(11), BlobOrNull(r, 12), BlobOrNull(r, 13));
public Channel? GetChannel(Id16 id) => public Channel? GetChannel(Id16 id) =>
One($"SELECT {ChannelCols} FROM channels WHERE id=@id", MapChannel, ("@id", id.ToArray())); One($"SELECT {ChannelCols} FROM channels WHERE id=@id", MapChannel, ("@id", id.ToArray()));
public void InsertChannel(Channel c) => Run( public void InsertChannel(Channel c) => Run(
$"INSERT INTO channels ({ChannelCols}) VALUES (@id,@mode,@settings,@rh,@wh,@oh,@created,@expires,@seq,@comp,@bytes)", $"INSERT INTO channels ({ChannelCols}) VALUES (@id,@mode,@settings,@rh,@wh,@oh,@created,@expires,@seq,@comp,@bytes,@account,@ph,@ps)",
("@id", c.Id.ToArray()), ("@mode", (int)c.Mode), ("@id", c.Id.ToArray()), ("@mode", (int)c.Mode),
("@settings", JsonSerializer.Serialize(c.Settings, CoreJsonContext.Default.ChannelSettings)), ("@settings", JsonSerializer.Serialize(c.Settings, CoreJsonContext.Default.ChannelSettings)),
("@rh", c.ReadHash), ("@wh", c.WriteHash), ("@oh", c.OwnerHash), ("@rh", c.ReadHash), ("@wh", c.WriteHash), ("@oh", c.OwnerHash),
("@created", c.CreatedAt), ("@expires", c.ExpiresAt), ("@created", c.CreatedAt), ("@expires", c.ExpiresAt),
("@seq", c.LastSeq), ("@comp", c.CompactedThrough), ("@bytes", c.Bytes)); ("@seq", c.LastSeq), ("@comp", c.CompactedThrough), ("@bytes", c.Bytes), ("@account", c.CreatorAccount), ("@ph", c.JoinPasswordHash), ("@ps", c.JoinPasswordSalt));
public void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt) => Run( public void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt) => Run(
"UPDATE channels SET settings=@s, expires_at=@e WHERE id=@id", "UPDATE channels SET settings=@s, expires_at=@e WHERE id=@id",
@@ -421,6 +437,36 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
public void TouchChannel(Id16 id, long expiresAt) => public void TouchChannel(Id16 id, long expiresAt) =>
Run("UPDATE channels SET expires_at=@e WHERE id=@id", ("@e", expiresAt), ("@id", id.ToArray())); Run("UPDATE channels SET expires_at=@e WHERE id=@id", ("@e", expiresAt), ("@id", id.ToArray()));
public void SetJoinPassword(Id16 id, byte[]? hash, byte[]? salt) => Run("UPDATE channels SET join_password_hash=@h,join_password_salt=@s WHERE id=@id", ("@h", hash), ("@s", salt), ("@id", id.ToArray()));
public bool HasInvitation(Id16 id, string username) => Scalar("SELECT 1 FROM channel_invitations WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username)) is not null;
public void PutInvitation(ChannelInvitation i) => Run("INSERT INTO channel_invitations(channel_id,username,payload,created_at) VALUES(@id,@u,@p,@t) ON CONFLICT(channel_id,username) DO UPDATE SET payload=excluded.payload,created_at=excluded.created_at", ("@id", i.ChannelId.ToArray()), ("@u", i.Username), ("@p", i.Payload), ("@t", i.CreatedAt));
private static ChannelInvitation MapInvitation(SqliteDataReader r) => new(IdAt(r, 0), r.GetString(1), r.GetString(2), r.GetInt64(3));
public IReadOnlyList<ChannelInvitation> ListInvitations(Id16 id) => Many("SELECT channel_id,username,payload,created_at FROM channel_invitations WHERE channel_id=@id", MapInvitation, ("@id", id.ToArray()));
public IReadOnlyList<ChannelInvitation> ListUserInvitations(string username) => Many("SELECT i.channel_id,i.username,i.payload,i.created_at FROM channel_invitations i JOIN channels c ON c.id=i.channel_id WHERE i.username=@u AND c.expires_at>@now AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id=i.channel_id AND m.username=i.username)", MapInvitation, ("@u", username), ("@now", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()));
public void DeleteInvitation(Id16 id, string username) => Run("DELETE FROM channel_invitations WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username));
public void PutKeyPackage(Id16 id, string username, string payload, long createdAt) => Run(
"INSERT INTO channel_key_packages(channel_id,username,payload,created_at) VALUES(@id,@u,@p,@t) ON CONFLICT(channel_id,username) DO UPDATE SET payload=excluded.payload,created_at=excluded.created_at",
("@id", id.ToArray()), ("@u", username), ("@p", payload), ("@t", createdAt));
public void DeleteKeyPackage(Id16 id, string username) => Run("DELETE FROM channel_key_packages WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username));
public IReadOnlyList<ChannelKeyPackage> ListUserKeyPackages(string username, long now) => Many(
"""
SELECT k.channel_id,k.username,m.role,k.payload,k.created_at FROM channel_key_packages k
JOIN channels c ON c.id=k.channel_id
JOIN channel_members m ON m.channel_id=k.channel_id AND m.username=k.username
WHERE k.username=@u AND c.expires_at>@now
""", r => new ChannelKeyPackage(IdAt(r, 0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetInt64(4)),
("@u", username), ("@now", now));
public void DeleteAccountSeats(Id16 id, string username) => Run("DELETE FROM seats WHERE channel_id=@id AND account_name=@u", ("@id", id.ToArray()), ("@u", username));
private static ChannelMember MapMember(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetInt64(2));
public ChannelMember? GetChannelMember(Id16 id, string username) => One("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id AND username=@u", MapMember, ("@id", id.ToArray()), ("@u", username));
public IReadOnlyList<ChannelMember> ListChannelMembers(Id16 id) => Many("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id ORDER BY joined_at,username", MapMember, ("@id", id.ToArray()));
public void PutChannelMember(Id16 id, ChannelMember member) => Run("INSERT OR IGNORE INTO channel_members(channel_id,username,role,joined_at) VALUES(@id,@u,@r,@t)", ("@id", id.ToArray()), ("@u", member.Username), ("@r", member.Role), ("@t", member.JoinedAt));
public void DeleteChannelMember(Id16 id, string username) => Run("DELETE FROM channel_members WHERE channel_id=@id AND username=@u AND role<>'owner'", ("@id", id.ToArray()), ("@u", username));
public bool IsJoinDenied(Id16 id, string? username, byte[] endpointHash) => Scalar("SELECT 1 FROM channel_join_denials WHERE channel_id=@id AND (target=@a OR target=@e) LIMIT 1", ("@id", id.ToArray()), ("@a", username is null ? null : "account:" + username), ("@e", "endpoint:" + Codec.Encode(endpointHash))) is not null;
public void DenyAccount(Id16 id, string username) => Run("INSERT OR IGNORE INTO channel_join_denials VALUES(@id,@t)", ("@id", id.ToArray()), ("@t", "account:" + username));
public void AllowAccount(Id16 id, string username) => Run("DELETE FROM channel_join_denials WHERE channel_id=@id AND target=@t", ("@id", id.ToArray()), ("@t", "account:" + username));
public void DenyEndpoint(Id16 id, byte[] endpointHash) => Run("INSERT OR IGNORE INTO channel_join_denials VALUES(@id,@t)", ("@id", id.ToArray()), ("@t", "endpoint:" + Codec.Encode(endpointHash)));
public void AddChannelBytes(Id16 id, long delta) => public void AddChannelBytes(Id16 id, long delta) =>
Run("UPDATE channels SET bytes=bytes+@d WHERE id=@id", ("@d", delta), ("@id", id.ToArray())); Run("UPDATE channels SET bytes=bytes+@d WHERE id=@id", ("@d", delta), ("@id", id.ToArray()));
@@ -439,6 +485,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
Run("DELETE FROM items WHERE channel_id=@c", ("@c", key)); Run("DELETE FROM items WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM seats WHERE channel_id=@c", ("@c", key)); Run("DELETE FROM seats WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channels WHERE id=@c", ("@c", key)); Run("DELETE FROM channels WHERE id=@c", ("@c", key));
Run("DELETE FROM channel_invitations WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channel_key_packages WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channel_members WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channel_join_denials WHERE channel_id=@c", ("@c", key));
} }
public IReadOnlyList<Id16> ListExpiredChannels(long now, int limit) => public IReadOnlyList<Id16> ListExpiredChannels(long now, int limit) =>
@@ -446,18 +496,18 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
// ───────── 席位 ───────── // ───────── 席位 ─────────
private static Seat MapSeat(SqliteDataReader r) => new(Blob(r, 0), r.GetInt64(1), BlobOrNull(r, 2)); private static Seat MapSeat(SqliteDataReader r) => new(Blob(r, 0), r.GetInt64(1), BlobOrNull(r, 2), r.IsDBNull(3) ? null : r.GetString(3), r.IsDBNull(4) ? null : r.GetString(4));
public Seat? GetSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => One( public Seat? GetSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => One(
"SELECT endpoint_hash, joined_at, label FROM seats WHERE channel_id=@c AND endpoint_hash=@e", "SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
MapSeat, ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())); MapSeat, ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray()));
public int CountSeats(Id16 channelId) => public int CountSeats(Id16 channelId) =>
(int)(long)Scalar("SELECT COUNT(*) FROM seats WHERE channel_id=@c", ("@c", channelId.ToArray()))!; (int)(long)Scalar("SELECT COUNT(*) FROM seats WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
public void InsertSeat(Id16 channelId, Seat seat) => Run( public void InsertSeat(Id16 channelId, Seat seat) => Run(
"INSERT INTO seats (channel_id, endpoint_hash, label, joined_at) VALUES (@c,@e,@l,@j)", "INSERT INTO seats (channel_id, endpoint_hash, label, joined_at,account_name,account_device_id) VALUES (@c,@e,@l,@j,@a,@d)",
("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt)); ("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt), ("@a", seat.AccountName), ("@d", seat.AccountDeviceId));
public bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => Run( public bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => Run(
"DELETE FROM seats WHERE channel_id=@c AND endpoint_hash=@e", "DELETE FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
@@ -468,27 +518,36 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
("@l", label), ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())); ("@l", label), ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray()));
public IReadOnlyList<Seat> ListSeats(Id16 channelId) => Many( public IReadOnlyList<Seat> ListSeats(Id16 channelId) => Many(
"SELECT endpoint_hash, joined_at, label FROM seats WHERE channel_id=@c ORDER BY joined_at, endpoint_hash", "SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c ORDER BY joined_at, endpoint_hash",
MapSeat, ("@c", channelId.ToArray())); MapSeat, ("@c", channelId.ToArray()));
public IReadOnlyList<Id16> DeleteDeviceSeats(string username, string deviceId)
{
var ids = Many("SELECT DISTINCT channel_id FROM seats WHERE account_name=@u AND account_device_id=@d", r => IdAt(r, 0), ("@u", username), ("@d", deviceId));
Run("DELETE FROM seats WHERE account_name=@u AND account_device_id=@d", ("@u", username), ("@d", deviceId));
return ids;
}
public void BindSeatDevice(Id16 id, byte[] endpointHash, string deviceId) => Run(
"UPDATE seats SET account_device_id=@d WHERE channel_id=@c AND endpoint_hash=@e",
("@d", deviceId), ("@c", id.ToArray()), ("@e", endpointHash));
// ───────── 条目 ───────── // ───────── 条目 ─────────
private static Item MapItem(SqliteDataReader r) => new( private static Item MapItem(SqliteDataReader r) => new(
IdAt(r, 0), IdAt(r, 1), Blob(r, 2), (FrameFlags)r.GetInt32(3), IdAt(r, 0), IdAt(r, 1), Blob(r, 2), (FrameFlags)r.GetInt32(3),
r.GetInt64(4), r.GetInt64(5), r.GetInt32(6), r.GetInt64(7), LongOrNull(r, 8), r.GetInt64(4), r.GetInt64(5), r.GetInt32(6), r.GetInt64(7), LongOrNull(r, 8),
r.GetInt64(9), r.GetInt64(10), r.GetInt64(11), LongOrNull(r, 12), r.GetInt64(9), r.GetInt64(10), r.GetInt64(11), LongOrNull(r, 12), r.IsDBNull(13) ? null : r.GetString(13));
r.IsDBNull(13) ? null : r.GetString(13));
public Item? GetItem(Id16 channelId, Id16 itemId) => One( public Item? GetItem(Id16 channelId, Id16 itemId) => One(
$"SELECT {ItemCols} FROM items WHERE channel_id=@c AND item_id=@i", $"SELECT {ItemCols} FROM items WHERE channel_id=@c AND item_id=@i",
MapItem, ("@c", channelId.ToArray()), ("@i", itemId.ToArray())); MapItem, ("@c", channelId.ToArray()), ("@i", itemId.ToArray()));
public void InsertItem(Item i) => Run( public void InsertItem(Item i) => Run(
$"INSERT INTO items ({ItemCols}) VALUES (@c,@i,@a,@f,@md,@d,@fc,@mi,@fi,@b,@cr,@ex,@cd,@l)", $"INSERT INTO items ({ItemCols}) VALUES (@c,@i,@a,@f,@md,@d,@fc,@mi,@fi,@b,@cr,@ex,@cd,@account)",
("@c", i.ChannelId.ToArray()), ("@i", i.ItemId.ToArray()), ("@a", i.AuthorHash), ("@c", i.ChannelId.ToArray()), ("@i", i.ItemId.ToArray()), ("@a", i.AuthorHash),
("@f", (int)i.Flags), ("@md", i.MaxDownloads), ("@d", i.Downloads), ("@f", (int)i.Flags), ("@md", i.MaxDownloads), ("@d", i.Downloads),
("@fc", i.FrameCount), ("@mi", i.MaxIndex), ("@fi", i.FinalIndex), ("@fc", i.FrameCount), ("@mi", i.MaxIndex), ("@fi", i.FinalIndex),
("@b", i.Bytes), ("@cr", i.CreatedAt), ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@l", i.Label)); ("@b", i.Bytes), ("@cr", i.CreatedAt), ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@account", i.AuthorAccount));
public void UpdateItem(Item i) => Run( public void UpdateItem(Item i) => Run(
"UPDATE items SET downloads=@d, frame_count=@fc, max_index=@mi, final_index=@fi, bytes=@b, expires_at=@ex, close_deadline=@cd WHERE channel_id=@c AND item_id=@i", "UPDATE items SET downloads=@d, frame_count=@fc, max_index=@mi, final_index=@fi, bytes=@b, expires_at=@ex, close_deadline=@cd WHERE channel_id=@c AND item_id=@i",
@@ -507,10 +566,11 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
} }
public int CountItems(Id16 channelId) => public int CountItems(Id16 channelId) =>
(int)(long)Scalar("SELECT COUNT(*) FROM items WHERE channel_id=@c", ("@c", channelId.ToArray()))!; (int)(long)Scalar("SELECT COUNT(*) FROM items WHERE channel_id=@c AND (flags & 16)=0", ("@c", channelId.ToArray()))!;
public int CountControlItems(Id16 channelId) => (int)(long)Scalar("SELECT COUNT(*) FROM items WHERE channel_id=@c AND (flags & 16)<>0", ("@c", channelId.ToArray()))!;
public IReadOnlyList<Item> ListOldestItems(Id16 channelId, int limit) => Many( public IReadOnlyList<Item> ListOldestItems(Id16 channelId, int limit) => Many(
$"SELECT {ItemCols} FROM items WHERE channel_id=@c ORDER BY created_at, rowid LIMIT @n", $"SELECT {ItemCols} FROM items WHERE channel_id=@c AND (flags & 16)=0 ORDER BY created_at, rowid LIMIT @n",
MapItem, ("@c", channelId.ToArray()), ("@n", limit)); MapItem, ("@c", channelId.ToArray()), ("@n", limit));
public Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId) => One( public Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId) => One(
@@ -0,0 +1,200 @@
using System.Net;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using WebDrop.Core;
using WebDrop.Storage;
using WebDrop.Api;
using Microsoft.Data.Sqlite;
using static WebDrop.Api.Tests.ServerFixture;
namespace WebDrop.Api.Tests;
public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<ServerFixture>
{
[Fact]
public async Task AccountKeyPackagesAreScopedToAcceptedMembersAndRemovedOnRevocation()
{
var ch = await server.CreateAsync(settings: new { seats = 8 });
var username = "keys_" + Guid.NewGuid().ToString("N")[..8]; var token = Register(username);
var member = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
using (var ownerPackage = server.Req(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/key-package", ch.Owner, JsonBody(new { payload = "opaque-owner-package" })))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(ownerPackage)).StatusCode);
using (var ownerList = server.Req(HttpMethod.Get, "/api/v1/accounts/channels", ch.Owner))
{
var rows = await ReadJsonAsync(await server.Http.SendAsync(ownerList));
Assert.Contains(rows.EnumerateArray(), row => row.GetProperty("channel_id").GetString() == ch.Id && row.GetProperty("role").GetString() == "owner");
}
using (var invite = server.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/invitations", ch.Owner, JsonBody(new { username, payload = "opaque-member-package" })))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(invite)).StatusCode);
using (var pending = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
Assert.Empty((await ReadJsonAsync(await server.Http.SendAsync(pending))).EnumerateArray());
using (var accept = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, token))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accept)).StatusCode);
using (var delivered = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
{
var rows = await ReadJsonAsync(await server.Http.SendAsync(delivered));
Assert.Equal("opaque-member-package", rows[0].GetProperty("payload").GetString()); Assert.Equal("member", rows[0].GetProperty("role").GetString());
}
using (var stealOwner = Request(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/key-package", ch.Owner, token, new { payload = "replaced" }))
await AssertErrorAsync(await server.Http.SendAsync(stealOwner), 401, "account_required");
using (var revoke = server.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/{username}", ch.Owner))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode);
using (var removed = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
Assert.Empty((await ReadJsonAsync(await server.Http.SendAsync(removed))).EnumerateArray());
using (var anonymous = Request(HttpMethod.Get, "/api/v1/accounts/channels", member))
await AssertErrorAsync(await server.Http.SendAsync(anonymous), 401, "account_required");
}
[Fact]
public async Task RevokingOneAccountDevicePreservesOtherDevicesAndChannelMembership()
{
var username = "devices_" + Guid.NewGuid().ToString("N")[..8];
var verifier = RandomNumberGenerator.GetBytes(32); var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); var point = key.ExportParameters(false).Q;
Assert.True(accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28], JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) })));
var firstId = Guid.NewGuid().ToString(); var secondId = Guid.NewGuid().ToString();
var firstToken = accounts.Authenticate(username, verifier, firstId, "Desktop")!;
var secondToken = accounts.Authenticate(username, verifier, secondId, "Phone")!;
var ch = await server.CreateAsync(settings: new { seats = 8 });
using (var invite = server.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/invitations", ch.Owner, JsonBody(new { username, payload = "opaque-key-package" })))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(invite)).StatusCode);
var first = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32)); var second = first with { EndpointSecret = RandomNumberGenerator.GetBytes(32) };
foreach (var (who, token) in new[] { (first, firstToken), (second, secondToken) })
{
using var joined = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who, token);
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(joined)).StatusCode);
}
Assert.Equal(2, accounts.Devices(username, firstId).Count);
Assert.Single(accounts.Devices(username, firstId), d => d.Current);
using (var impersonate = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", second, firstToken))
await AssertErrorAsync(await server.Http.SendAsync(impersonate), 401, "account_required");
using (var revoke = Request(HttpMethod.Delete, $"/api/v1/accounts/devices/{secondId}", first, firstToken))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode);
Assert.True(accounts.HasSession(firstToken, out _)); Assert.False(accounts.HasSession(secondToken, out _));
Assert.Single(accounts.Devices(username, firstId));
using (var read = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", first, firstToken))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(read)).StatusCode);
using (var packages = Request(HttpMethod.Get, "/api/v1/accounts/channels", first, firstToken))
Assert.Single((await ReadJsonAsync(await server.Http.SendAsync(packages))).EnumerateArray());
}
[Fact]
public void PartialSettingsPreserveAdmissionDefaults()
{
var request = JsonSerializer.Deserialize("{\"settings\":{\"seats\":8}}", ApiJson.Default.CreateChannelRequest)!;
Assert.Equal("open", request.Settings!.JoinPolicy);
Assert.True(request.Settings.AllowGuests);
request.Settings.Validate(new ServerLimits());
var share = JsonSerializer.Deserialize("{\"settings\":{\"max_visitors\":1}}", ApiJson.Default.CreateShareRequest)!;
Assert.Equal(86400, share.Settings!.TtlSeconds); share.Settings.Validate(new ServerLimits());
}
[Fact]
public async Task NullCredentialsAreRejectedWith400InsteadOf500()
{
foreach (var path in new[] { "/api/v1/accounts/salt", "/api/v1/accounts/session" })
{
using var response = await server.Http.PostAsync(path, JsonBody(new { username = (string?)null, verifier = (string?)null }));
await AssertErrorAsync(response, 400, "bad_request");
}
}
private string Register(string username)
{
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
var verifier = RandomNumberGenerator.GetBytes(32);
using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
var point = key.ExportParameters(false).Q;
Assert.True(accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28],
JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) })));
return accounts.Authenticate(username, verifier)!;
}
private HttpRequestMessage Request(HttpMethod method, string path, Who who, string? account = null, object? body = null)
{
var request = server.Req(method, path, who, body is null ? null : JsonBody(body));
request.Headers.Remove("X-WD-Account");
if (account is not null) request.Headers.Add("X-WD-Account", account);
return request;
}
[Fact]
public async Task CreationAndOwnerActionsRequireAccountIdentity()
{
var ch = await server.CreateAsync();
foreach (var path in new[] { "/api/v1/channels", "/api/v1/shares" })
{
using var request = Request(HttpMethod.Post, path, ch.Owner, body: new { });
await AssertErrorAsync(await server.Http.SendAsync(request), 401, "account_required");
}
using var infoResponse = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner));
Assert.Equal("fixture_owner", (await ReadJsonAsync(infoResponse)).GetProperty("creator_account").GetString());
using var membersResponse = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/members", ch.Owner));
var members = await ReadJsonAsync(membersResponse);
Assert.Equal("owner", members[0].GetProperty("role").GetString());
var unrelated = Register("unrelated_" + Guid.NewGuid().ToString("N")[..8]);
using var copied = Request(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, unrelated, new { name = "stolen" });
await AssertErrorAsync(await server.Http.SendAsync(copied), 401, "account_required");
}
[Theory]
[InlineData(ChannelMode.Enc)]
[InlineData(ChannelMode.Raw)]
public async Task PasswordInvitationsAndRevocationUseTheSameChannelRules(ChannelMode mode)
{
var ch = await server.CreateAsync(mode, new { seats = 8 });
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_password = "channel-secret" })))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
var guest = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32));
using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest))
await AssertErrorAsync(await server.Http.SendAsync(missing), 403, "channel_password_required");
using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest, body: new { password = "wrong" }))
await AssertErrorAsync(await server.Http.SendAsync(wrong), 403, "channel_password_invalid");
using (var correct = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest, body: new { password = "channel-secret" }))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(correct)).StatusCode);
using (var closed = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { allow_guests = false })))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(closed)).StatusCode);
using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }, body: new { password = "channel-secret" }))
await AssertErrorAsync(await server.Http.SendAsync(stranger), 401, "account_required");
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_policy = "invite", allow_guests = false })))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
var username = "invited_" + Guid.NewGuid().ToString("N")[..8];
var account = Register(username);
var member = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32));
using (var uninvited = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
await AssertErrorAsync(await server.Http.SendAsync(uninvited), 403, "invitation_required");
using (var invite = server.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/invitations", ch.Owner, JsonBody(new { username, payload = "client-encrypted-payload" })))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(invite)).StatusCode);
using (var accepted = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accepted)).StatusCode);
using (var impersonated = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member))
await AssertErrorAsync(await server.Http.SendAsync(impersonated), 401, "account_required");
var writer = member with { Role = "write", Token = ch.WriteToken };
using (var upload = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", writer, account))
{
upload.Content = Bytes(Frame(NewId(), 0, (byte)(1 | 16 | (mode == ChannelMode.Enc ? 8 : 0)), mode == ChannelMode.Enc ? EncPayload : 16));
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(upload)).StatusCode);
}
using (var revoke = server.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/{username}", ch.Owner))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode);
using (var rejoin = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
await AssertErrorAsync(await server.Http.SendAsync(rejoin), 403, "forbidden");
}
[Fact]
public async Task StoredLoginDigestCannotBeUsedAsLoginCredentialAndVaultUpdatesAreConditional()
{
var username = "digest_" + Guid.NewGuid().ToString("N")[..8];
var token = Register(username);
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
Assert.Null(accounts.Authenticate(username, new byte[32]));
using var database = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = Path.Combine(server.App.Services.GetRequiredService<WebDropOptions>().DataDir, "v2", "webdrop.db") }.ToString());
database.Open();
using var query = database.CreateCommand(); query.CommandText = "SELECT verifier FROM accounts WHERE username=$u"; query.Parameters.AddWithValue("$u", username);
var storedDigest = (byte[])query.ExecuteScalar()!;
Assert.Null(accounts.Authenticate(username, storedDigest));
Assert.True(accounts.HasSession(token, out var actual)); Assert.Equal(username, actual);
var vault = accounts.GetVault(username)!.Value;
Assert.Null(accounts.PutVault(username, new byte[28], vault.Revision + 1));
Assert.Equal(vault.Revision + 1, accounts.PutVault(username, new byte[28], vault.Revision));
accounts.RevokeSession(token); Assert.False(accounts.HasSession(token, out _));
}
}
+1 -1
View File
@@ -193,7 +193,7 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
// 超过 1 MiB 负载上限但小于 Kestrel 的 2 MiB:服务自己拒绝 // 超过 1 MiB 负载上限但小于 Kestrel 的 2 MiB:服务自己拒绝
await AssertErrorAsync(await s.AppendAsync(ch, ch.Owner, Frame(NewId(), 0, 1, 1_500_000)), 413, "too_large"); await AssertErrorAsync(await s.AppendAsync(ch, ch.Owner, Frame(NewId(), 0, 1, 1_500_000)), 413, "too_large");
// 超过 Kestrel 上限:框架拒绝,仍映射为同一错误码 // 超过 Kestrel 上限:框架拒绝,仍映射为同一错误码
await AssertErrorAsync(await s.AppendAsync(ch, ch.Owner, Frame(NewId(), 0, 1, 3_000_000)), 413, "too_large"); await AssertErrorAsync(await s.AppendAsync(ch, ch.Owner, Frame(NewId(), 0, 1, 3_000_000), expectContinue: true), 413, "too_large");
} }
[Fact] [Fact]
@@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using WebDrop.Api; using WebDrop.Api;
using WebDrop.Core; using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api.Tests; namespace WebDrop.Api.Tests;
@@ -21,6 +22,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
public WebApplication App { get; private set; } = null!; public WebApplication App { get; private set; } = null!;
public HttpClient Http { get; private set; } = null!; public HttpClient Http { get; private set; } = null!;
public Uri BaseUri { get; private set; } = null!; public Uri BaseUri { get; private set; } = null!;
public string OwnerAccountToken { get; private set; } = null!;
public ServerFixture() : this([]) { } public ServerFixture() : this([]) { }
@@ -45,6 +47,12 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First(); string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
BaseUri = new Uri(address); BaseUri = new Uri(address);
Http = new HttpClient { BaseAddress = BaseUri, Timeout = TimeSpan.FromSeconds(30) }; Http = new HttpClient { BaseAddress = BaseUri, Timeout = TimeSpan.FromSeconds(30) };
var accounts = App.Services.GetRequiredService<SqliteAccountStore>();
var verifier = RandomNumberGenerator.GetBytes(32);
using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
var point = key.ExportParameters(false).Q;
accounts.Register("fixture_owner", RandomNumberGenerator.GetBytes(16), verifier, new byte[28], JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) }));
OwnerAccountToken = accounts.Authenticate("fixture_owner", verifier)!;
} }
public async Task DisposeAsync() public async Task DisposeAsync()
@@ -52,7 +60,6 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
Http.Dispose(); Http.Dispose();
await App.StopAsync(); await App.StopAsync();
await App.DisposeAsync(); await App.DisposeAsync();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { } try { Directory.Delete(_dir, recursive: true); } catch (IOException) { }
} }
@@ -86,6 +93,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
req.Headers.Add("X-WD-Role", who.Role); req.Headers.Add("X-WD-Role", who.Role);
if (who.Token is not null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", B64(who.Token)); if (who.Token is not null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", B64(who.Token));
req.Headers.Add("X-WD-Endpoint", B64(who.EndpointSecret)); req.Headers.Add("X-WD-Endpoint", B64(who.EndpointSecret));
if (who.Role == "owner") req.Headers.Add("X-WD-Account", OwnerAccountToken);
return req; return req;
} }
@@ -129,10 +137,16 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
public async Task<HttpResponseMessage> JoinAsync(Chan ch, Who who) => public async Task<HttpResponseMessage> JoinAsync(Chan ch, Who who) =>
await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who)); await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who));
public async Task<HttpResponseMessage> AppendAsync(Chan ch, Who who, byte[] frame, string? maxDownloads = null) public async Task<HttpResponseMessage> AppendAsync(
Chan ch,
Who who,
byte[] frame,
string? maxDownloads = null,
bool expectContinue = false)
{ {
var req = Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", who, Bytes(frame)); var req = Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", who, Bytes(frame));
if (maxDownloads is not null) req.Headers.Add("X-WD-Max-Downloads", maxDownloads); if (maxDownloads is not null) req.Headers.Add("X-WD-Max-Downloads", maxDownloads);
req.Headers.ExpectContinue = expectContinue;
return await Http.SendAsync(req); return await Http.SendAsync(req);
} }
@@ -20,6 +20,7 @@ public class StreamingTests(ServerFixture s) : IClassFixture<ServerFixture>
ws.Options.AddSubProtocol($"wd.role.{who.Role}"); ws.Options.AddSubProtocol($"wd.role.{who.Role}");
if (who.Token is not null) ws.Options.AddSubProtocol($"wd.auth.{B64(who.Token)}"); if (who.Token is not null) ws.Options.AddSubProtocol($"wd.auth.{B64(who.Token)}");
ws.Options.AddSubProtocol($"wd.ep.{B64(who.EndpointSecret)}"); ws.Options.AddSubProtocol($"wd.ep.{B64(who.EndpointSecret)}");
if (who.Role == "owner") ws.Options.AddSubProtocol($"wd.account.{B64(Convert.FromBase64String(s.OwnerAccountToken))}");
var uri = new UriBuilder(s.BaseUri) { Scheme = "ws", Path = $"/api/v1/channels/{ch.Id}/ws", Query = $"from={from}" }.Uri; var uri = new UriBuilder(s.BaseUri) { Scheme = "ws", Path = $"/api/v1/channels/{ch.Id}/ws", Query = $"from={from}" }.Uri;
using var cts = new CancellationTokenSource(Timeout); using var cts = new CancellationTokenSource(Timeout);
@@ -20,8 +20,8 @@ public class ChannelServiceTests : IDisposable
private static void AssertCode(ErrorCode code, Action act) => private static void AssertCode(ErrorCode code, Action act) =>
Assert.Equal(code, Assert.Throws<WebDropException>(act).Code); Assert.Equal(code, Assert.Throws<WebDropException>(act).Code);
private Task<AppendResult> Append(TestChannel ch, Credentials who, byte[] frame, long? maxDownloads = null, string? label = null) => private Task<AppendResult> Append(TestChannel ch, Credentials who, byte[] frame, long? maxDownloads = null) =>
Svc.AppendFrameAsync(ch.Id, who, frame, maxDownloads, label); Svc.AppendFrameAsync(ch.Id, who, frame, maxDownloads);
private Task<AppendResult> AppendSmall(TestChannel ch, Credentials who, Id16? item = null, byte fill = 0x42, FrameFlags extra = FrameFlags.None) => private Task<AppendResult> AppendSmall(TestChannel ch, Credentials who, Id16? item = null, byte fill = 0x42, FrameFlags extra = FrameFlags.None) =>
Append(ch, who, TestEnv.Frame(item ?? TestEnv.NewId(), 0, TestEnv.SmallEnc | extra, TestEnv.EncPayload, fill)); Append(ch, who, TestEnv.Frame(item ?? TestEnv.NewId(), 0, TestEnv.SmallEnc | extra, TestEnv.EncPayload, fill));
@@ -44,7 +44,7 @@ public class ChannelServiceTests : IDisposable
ReadHash = Codec.Encode(TokenHash.Compute(ch.ReadToken)), ReadHash = Codec.Encode(TokenHash.Compute(ch.ReadToken)),
OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)), OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)),
}; };
AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, _ownerEp)); AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, _ownerEp, "owner"));
} }
[Fact] [Fact]
@@ -55,9 +55,9 @@ public class ChannelServiceTests : IDisposable
var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, OwnerHash = h() }; var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, OwnerHash = h() };
var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw }; var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw };
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp)); AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp, "owner"));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp)); AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp, "owner"));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, _ownerEp)); AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, _ownerEp, "owner"));
} }
[Fact] [Fact]
@@ -60,7 +60,7 @@ public class EnvelopeTests
public void RejectsReservedFlagBits() public void RejectsReservedFlagBits()
{ {
var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 4); var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 4);
bytes[1] |= 0x10; bytes[1] |= 0x20;
Assert.False(Envelope.TryParse(bytes, out _)); Assert.False(Envelope.TryParse(bytes, out _));
} }
@@ -110,8 +110,17 @@ public class AuthTests
public class PolicyTests public class PolicyTests
{ {
[Fact]
public void AuthenticatedAuthorsCanManageOwnItemsAcrossDevices()
{
var first = TestEnv.Endpoint(); var second = TestEnv.Endpoint();
var item = ItemBy(first) with { AuthorAccount = "member" };
var channel = Chan(new ChannelSettings { DeletePolicy = DeletePolicy.Own });
Assert.True(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, second, "member")));
Assert.False(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, first, "other-account")));
}
private static Channel Chan(ChannelSettings s, bool writeHash = true) => new( private static Channel Chan(ChannelSettings s, bool writeHash = true) => new(
TestEnv.NewId(), ChannelMode.Enc, s, new byte[32], writeHash ? new byte[32] : null, new byte[32], 0, 0, 0, 0, 0); TestEnv.NewId(), ChannelMode.Enc, s, new byte[32], writeHash ? new byte[32] : null, new byte[32], 0, 0, 0, 0, 0, "owner");
private static Item ItemBy(byte[] author) => new( private static Item ItemBy(byte[] author) => new(
TestEnv.NewId(), TestEnv.NewId(), author, FrameFlags.None, 0, 0, 0, -1, null, 0, 0, 0, null, null); TestEnv.NewId(), TestEnv.NewId(), author, FrameFlags.None, 0, 0, 0, -1, null, 0, 0, 0, null, null);
+6 -6
View File
@@ -22,7 +22,7 @@ public sealed class RecordingEvents : IChannelEvents
} }
/// <summary>一个频道的全部凭据,模拟客户端从 K / A 派生出的令牌。</summary> /// <summary>一个频道的全部凭据,模拟客户端从 K / A 派生出的令牌。</summary>
public sealed class TestChannel(Id16 id, ChannelMode mode, byte[] read, byte[] write, byte[] owner) public sealed class TestChannel(Id16 id, ChannelMode mode, byte[] read, byte[] write, byte[] owner, byte[] ownerEndpoint)
{ {
public Id16 Id { get; } = id; public Id16 Id { get; } = id;
public ChannelMode Mode { get; } = mode; public ChannelMode Mode { get; } = mode;
@@ -30,9 +30,9 @@ public sealed class TestChannel(Id16 id, ChannelMode mode, byte[] read, byte[] w
public byte[] WriteToken { get; } = write; public byte[] WriteToken { get; } = write;
public byte[] OwnerToken { get; } = owner; public byte[] OwnerToken { get; } = owner;
public Credentials Owner(byte[] endpoint) => new(Role.Owner, OwnerToken, endpoint); public Credentials Owner(byte[] endpoint) => new(Role.Owner, OwnerToken, endpoint, "owner");
public Credentials Member(byte[] endpoint) => new(Role.Write, WriteToken, endpoint); public Credentials Member(byte[] endpoint) => new(Role.Write, WriteToken, endpoint, endpoint.AsSpan().SequenceEqual(ownerEndpoint) ? "owner" : null);
public Credentials Reader(byte[] endpoint) => new(Role.Read, ReadToken, endpoint); public Credentials Reader(byte[] endpoint) => new(Role.Read, ReadToken, endpoint, endpoint.AsSpan().SequenceEqual(ownerEndpoint) ? "owner" : null);
} }
public sealed class TestEnv : IDisposable public sealed class TestEnv : IDisposable
@@ -76,9 +76,9 @@ public sealed class TestEnv : IDisposable
ReadHash = mode == ChannelMode.Enc ? Codec.Encode(TokenHash.Compute(read)) : null, ReadHash = mode == ChannelMode.Enc ? Codec.Encode(TokenHash.Compute(read)) : null,
WriteHash = withWriteHash ? Codec.Encode(TokenHash.Compute(write)) : null, WriteHash = withWriteHash ? Codec.Encode(TokenHash.Compute(write)) : null,
OwnerHash = Codec.Encode(TokenHash.Compute(owner)), OwnerHash = Codec.Encode(TokenHash.Compute(owner)),
}, ownerEndpoint); }, ownerEndpoint, "owner");
return new TestChannel(id, mode, read, write, owner); return new TestChannel(id, mode, read, write, owner, ownerEndpoint);
} }
/// <summary>构造一个封装。enc 模式的负载长度必须是 档位+16。</summary> /// <summary>构造一个封装。enc 模式的负载长度必须是 档位+16。</summary>
@@ -0,0 +1,24 @@
using WebDrop.Core;
namespace WebDrop.Core.Tests;
public sealed class WorkspaceControlTests
{
[Theory]
[InlineData(ChannelMode.Enc)]
[InlineData(ChannelMode.Raw)]
public async Task ThreadStructureSurvivesContentExpiryAndEviction(ChannelMode mode)
{
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint();
var channel = env.Create(endpoint, mode, new ChannelSettings { TtlSeconds = 60, MaxItems = 1 });
var id = TestEnv.NewId(); var control = FrameFlags.Final | FrameFlags.Control | (mode == ChannelMode.Enc ? FrameFlags.Enc : 0);
var normal = FrameFlags.Final | (mode == ChannelMode.Enc ? FrameFlags.Enc : 0);
int payload = mode == ChannelMode.Enc ? TestEnv.EncPayload : 16;
await env.Service.AppendFrameAsync(channel.Id, channel.Owner(endpoint), TestEnv.Frame(id, 0, control, payload), null);
env.Clock.Advance(TimeSpan.FromSeconds(59));
for (int i = 0; i < 3; i++) await env.Service.AppendFrameAsync(channel.Id, channel.Owner(endpoint), TestEnv.Frame(TestEnv.NewId(), 0, normal, payload), null);
env.Clock.Advance(TimeSpan.FromSeconds(2)); env.Service.Sweep();
Assert.NotNull(env.Store.Read(tx => tx.GetItem(channel.Id, id)));
Assert.Equal(1, env.Store.Read(tx => tx.CountControlItems(channel.Id)));
Assert.Equal(1, env.Store.Read(tx => tx.CountItems(channel.Id)));
}
}
+15 -4
View File
@@ -32,7 +32,7 @@ const hkdf = (ikm, salt, info) => Buffer.from(hkdfSync('sha256', ikm, salt, Buff
// ───────────── 协议常量(protocol.md) ───────────── // ───────────── 协议常量(protocol.md) ─────────────
const FLAG = { FINAL: 1, SNAPSHOT: 2, GATED: 4, ENC: 8 }; const FLAG = { FINAL: 1, SNAPSHOT: 2, GATED: 4, ENC: 8, CONTROL: 16 };
const TIERS = [1024, 4096, 16384, 65536, 262144, 1048576]; const TIERS = [1024, 4096, 16384, 65536, 262144, 1048576];
const MAX_FRAME_DATA = 1048576 - 4; const MAX_FRAME_DATA = 1048576 - 4;
const LABEL = { const LABEL = {
@@ -111,11 +111,13 @@ function metaJson(m) {
if (m.size !== undefined) o.size = m.size; if (m.size !== undefined) o.size = m.size;
o.ts = m.ts; o.ts = m.ts;
if (m.dev !== undefined) o.dev = m.dev; if (m.dev !== undefined) o.dev = m.dev;
if (m.threadId !== undefined) o.thread_id = m.threadId;
if (m.folderId !== undefined) o.folder_id = m.folderId;
return Buffer.from(JSON.stringify(o), 'utf8'); return Buffer.from(JSON.stringify(o), 'utf8');
} }
/** 条目编码的参照实现:小内容单帧(非 GATED),放不下则多帧 GATED。 */ /** 条目编码的参照实现:小内容单帧(非 GATED),放不下则多帧 GATED。 */
function encodeItem({ itemId, meta, content, maxFrameData = MAX_FRAME_DATA, snapshot = false, forceGated = false }) { function encodeItem({ itemId, meta, content, maxFrameData = MAX_FRAME_DATA, snapshot = false, forceGated = false, control = false }) {
const mj = metaJson(meta); const mj = metaJson(meta);
const head = Buffer.concat([u16(mj.length), mj]); const head = Buffer.concat([u16(mj.length), mj]);
const cap0 = maxFrameData - head.length; const cap0 = maxFrameData - head.length;
@@ -123,7 +125,7 @@ function encodeItem({ itemId, meta, content, maxFrameData = MAX_FRAME_DATA, snap
const frames = []; const frames = [];
if (content.length <= cap0 && !forceGated) { if (content.length <= cap0 && !forceGated) {
frames.push({ index: 0, flags: FLAG.FINAL | FLAG.ENC | (snapshot ? FLAG.SNAPSHOT : 0), data: Buffer.concat([head, content]) }); frames.push({ index: 0, flags: FLAG.FINAL | FLAG.ENC | (snapshot ? FLAG.SNAPSHOT : 0) | (control ? FLAG.CONTROL : 0), data: Buffer.concat([head, content]) });
return { gated: false, frames }; return { gated: false, frames };
} }
if (snapshot) throw new Error('快照不能是 GATED'); if (snapshot) throw new Error('快照不能是 GATED');
@@ -216,6 +218,7 @@ vectors.frames = frameCases.map((c) => {
// 条目向量:meta + 内容 + 分帧 // 条目向量:meta + 内容 + 分帧
const TS = 1700000000000; const TS = 1700000000000;
const itemCases = [ const itemCases = [
{ name: 'workspace_control', itemId: itemC, meta: { kind: 'other', ts: TS }, control: true, content: Buffer.from('{"app":"webdrop.workspace","v":1,"op":"thread.create"}', 'utf8') },
{ {
name: 'text_small', name: 'text_small',
itemId: itemA, itemId: itemA,
@@ -241,6 +244,13 @@ const itemCases = [
meta: { kind: 'file', name: 'note.txt', mime: 'text/plain', size: 11, ts: TS }, meta: { kind: 'file', name: 'note.txt', mime: 'text/plain', size: 11, ts: TS },
content: Buffer.from('hello world', 'utf8'), content: Buffer.from('hello world', 'utf8'),
}, },
{
name: 'file_in_thread_folder',
itemId: itemC,
meta: { kind: 'file', name: 'readme.txt', mime: 'text/plain', size: 11, ts: TS, dev: 'Windows 11',
threadId: '01234567-89ab-cdef-0123-456789abcdef', folderId: 'fedcba98-7654-3210-fedc-ba9876543210' },
content: Buffer.from('thread test', 'utf8'),
},
{ {
name: 'empty_text', name: 'empty_text',
itemId: itemB, itemId: itemB,
@@ -296,6 +306,7 @@ vectors.items = itemCases.map((c) => {
max_frame_data: c.maxFrameData ?? MAX_FRAME_DATA, max_frame_data: c.maxFrameData ?? MAX_FRAME_DATA,
snapshot: !!c.snapshot, snapshot: !!c.snapshot,
force_gated: !!c.forceGated, force_gated: !!c.forceGated,
control: !!c.control,
gated: plan.gated, gated: plan.gated,
frames: envelopes.map(hex), frames: envelopes.map(hex),
}; };
@@ -381,7 +392,7 @@ vectors.negative = [
{ name: 'header_only', envelope: hex(base.subarray(0, 26)), expect: 'bad_envelope' }, { name: 'header_only', envelope: hex(base.subarray(0, 26)), expect: 'bad_envelope' },
{ name: 'too_short', envelope: hex(base.subarray(0, 10)), expect: 'bad_envelope' }, { name: 'too_short', envelope: hex(base.subarray(0, 10)), expect: 'bad_envelope' },
{ name: 'bad_version', envelope: mutate((b) => { b[0] = 2; }), expect: 'bad_envelope' }, { name: 'bad_version', envelope: mutate((b) => { b[0] = 2; }), expect: 'bad_envelope' },
{ name: 'reserved_flag_bit', envelope: mutate((b) => { b[1] |= 0x10; }), expect: 'bad_envelope' }, { name: 'reserved_flag_bit', envelope: mutate((b) => { b[1] |= 0x20; }), expect: 'bad_envelope' },
{ name: 'payload_len_mismatch', envelope: mutate((b) => { b[25] ^= 1; }), expect: 'bad_envelope' }, { name: 'payload_len_mismatch', envelope: mutate((b) => { b[25] ^= 1; }), expect: 'bad_envelope' },
// 没有 ENC 标志的帧,在 enc 会话里必须被拒绝(防降级:服务端不能注入明文帧) // 没有 ENC 标志的帧,在 enc 会话里必须被拒绝(防降级:服务端不能注入明文帧)
{ {
+54 -1
View File
@@ -214,6 +214,25 @@
} }
], ],
"items": [ "items": [
{
"name": "workspace_control",
"item_id": "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf",
"meta": {
"v": 1,
"kind": "other",
"ts": 1700000000000
},
"meta_json": "{\"v\":1,\"kind\":\"other\",\"ts\":1700000000000}",
"content": "7b22617070223a2277656264726f702e776f726b7370616365222c2276223a312c226f70223a227468726561642e637265617465227d",
"max_frame_data": 1048572,
"snapshot": false,
"force_gated": false,
"control": true,
"gated": false,
"frames": [
"0119d0d1d2d3d4d5d6d7d8d9dadbdcdddedf0000000000000410b7c763b62f571aed03d822e5fc9b5febf11d186b288c4333bb4622d815cc7cce6685fe3c4df5bf933b74c83d8a0fcb68b52a822fde949134cf7f03787fe214f5d406e832550e291da980b3cba47c775acd9f640b38c528915a896571569a7c8b68b3bd68b040bf8fa99f302268fd0eca75c5bd627b3e4e35ab40fbc0b895caafc2c5d3b3d3e44098064e17a4eb1895055bf9f7936c2a33021c7da0078528167b2c00f299814ef5bfdb6bbfe70d25731d200bcbb92af971eea0824667f896d6376e0aab89dd5a1228be6bccb9379dfdc79e7074d4d5f3c604b3c709348c35c92de47f30ec82073c9a8d935dd83e3ad300677e5e1261da074c4378f7b521a84934b3dc7664f7e26b6b9e0c6c52f2200172923ea3794bd93243829d26e532f2c0c50dde1a46d3ff87127ba1702d73a0323913523e8a8aabcca2c1315739e93bfc783773c186b7ff50c91e9dd18cdc38de9690499fd05adad02db38e1dbc58b12968f001ec162248cba9d84286b3f413f27b98243875aeab677bf6b740267e642598331534d1c0c5c03a2cbaec39677ab3c12069c93b617d286aa85268a42d9d181b0e2c1381b6a1d5f4c008b1ec849c33fe12c3f53e3a74f9ee1f262c47c8adcf60081b5444e4a91d88e99131e3d8067fab78615704d335e0a6881eff89a5053af201fb2d1eadc53ea18a32c1cd1a1043ae9074821e396788550d6a403f257515340c424a86d62622409127c7faae301b589c2e31da258b0e9552cff0b0ca986320e46a3f97649848faddf038cda376c0fb69ed7cda1e2abeb33cc1ee9cbaf187a2dc33a6d896c909897e3ce543b38478d4e97a0c7a78837abb0ecf06044bfc75e8bdcd5bc7d3d94b18a459cb8f7973ea7047407a9e755cda9b276fa0a45e1e1ed9dd1172a05d5684576305c7bd10e79b5aa621556215b6b662884f78274c5631261de9dedadb6bcada81a309d8792179dab25096ceedfc15fca3b3209b011075393df693fe6edc12cfab29a138ddae1cc0022a172461a420c4d0a60b7b056d82f44775a298e2322ab7106e5fa62378c902ae9aece2819de44c5e9ce46bb597a427e78f6bdc21b9e7bbe153fa4c030a13bf156ba506220a66f2df12b9e9c4aa574fe3cd231c910e3ada3416ed604ddd1debc3029ab903f1555108c02d36b3073c89b79c118c875e0c4f8af3bae2d0697e05efe7e5a7e7a9f360820f854b7835012e8913c91f5cca77dfa8bcef5f1384bd7bdf7767061b4c332b52020f1f8f0f36fa126c52f6c36d1689a5e99dde6b02d92834eece6eba94e997591714a0a692652539894d6caa1640d8ec3d28e699fc4f4f1fae62ddd40c46adfe891c2d6142f89fea0329c5b9504a9069a8aa29b85374dac5acfc5be7c7683ae7d98ce0826164c8d8b21f75f06e2c5648bad52d5878596cde0a948d9fdccccbd53ec3b1c3e3922da83bb0ec65c24537d83371b236dd6d7f"
]
},
{ {
"name": "text_small", "name": "text_small",
"item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", "item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf",
@@ -227,6 +246,7 @@
"max_frame_data": 1048572, "max_frame_data": 1048572,
"snapshot": false, "snapshot": false,
"force_gated": false, "force_gated": false,
"control": false,
"gated": false, "gated": false,
"frames": [ "frames": [
"0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a28e3d72b8892fa67302d87ec18c8cc4c994d9de36816339181f976623149c97875b81538c552b91afc51004ad6e248676e829e4961a7de3055b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67053d1bece4e7ae471253b03e4702dc5b" "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a28e3d72b8892fa67302d87ec18c8cc4c994d9de36816339181f976623149c97875b81538c552b91afc51004ad6e248676e829e4961a7de3055b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67053d1bece4e7ae471253b03e4702dc5b"
@@ -246,6 +266,7 @@
"max_frame_data": 1048572, "max_frame_data": 1048572,
"snapshot": false, "snapshot": false,
"force_gated": false, "force_gated": false,
"control": false,
"gated": false, "gated": false,
"frames": [ "frames": [
"0109b0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89cae5dcd32135fd5a443bcd7dd5f1c052c986f5512cccd54163df53fbd7a420a42a32a38827e0ccf6a303778ff3dfacb0cad8ceebd64e092bed2663427a091204f6b5f186f71a1bd5c534976aba8b313018197269d810f5bc6fe0ca03bfc9b05a1aec5ddc34855519f3d2d6837f1d859dbe9543e2b188e5cd19037aed96ecb53247ab126e58cee2fcddd50c78cd46453f19a6df767c110a1a59ceaf4090e1f49d3af460d539376d0e173f736b54a871843c2bfc7d18e60d1df6ada80f29ba6e0b5f33753fe162da11ed1c10dff3f0eb3d29c643f02e319cbae99911f3e9dbe163db41e7dbee7b5b92fe07794a1cbaadccf488b7ac1f41328bb02077ca0ad6d1dc4bfd92a072dad9567ddd98455915fb81bf4997a1bd78e4d960b1e944b56746520afd73cabe651625c060da92a499366ec0e81df8e73ab7a1ab6354a97c9807d3f329ce19242b4e16ec4eb65e49af4e4a9c0ed54464522c98b98a30c45b5acce7b76033953602ce26c358c78e1c8875d44e0bae41243089f5408a2a945a8fb408b2dd0b96d05ad14448c3334b2d5c27b73ef4131fb04b1ecda32422ab9620e3a114fe8ef11dd3856d095b8e6974eb53926b293134200336e72a92d665af622fcdec19fd072cfc493905a73dc5d856433293a24c60fdcbe9e7ea45b394e60d2fbfdef69f0d1e4fa430c297e81fa01498b3d9781c33ad80d8c5fa933e81f8bdd3d9afd0d47259970af52e5b01d636ea92d75fbd671b0342434573a83bf919c1d14a55b2f3e3c67e3cf809be5b50ce4bc19571a50c70c6fb86ed9951f98f03d3d6af1fcd5832a148859bb895a91fe9c905816e33df43b5c666b990baa1f97526620a1c87fdf518713222e618815023d5db0b0066dc81f3b13884545d6a510f5d11947e9a89c90baeee052cd3578d14999eac3eb2139e263bb8f3c6a8ccc5e935b2fced80b5803f7bb76a48ddc560758a6d7c6db681fef00a6377afd2d5aa2e77a60fb5bde864f4f180090ba2948e6429a8acbb3be6a5e240521a445b41da8d29eb5f1171dfd3248121f882aa0ea35c5bfa3b464d1088ee841f8c140564076d5ee342b20cde2218aa673c9c55fb3317696692e40d7895b1a8ecf6ecb9d308d72e0ef39bf97bdce8025d02faf27dda3b1cf99ccbaf628d090f195da8e2ddae82df934cfb7862f9d54beb2b2dafe38d5f9950128b387f2b0a6cfbc604c844175c642707ec35f3b27ba693211be15ff995b545ce1d2d16adcd5ca23eb4613bd1cf1c5d13ce9caeb9e154ca0eab40c01b11d6506a9a6a5b0b285ff553903691242b85ad2b5e5718d24b8c8853baf605c44a2156bfc4b734f2d4e1b3caf30299ef464e83a0bbec01ae74743657b63be4d9998a18d49d6a48c926c51c2b6347057eeee6ebe27b7f00d9403729e88734e9b6f65d3b6795fa278765376e8732853173b84d13bb985883794cf8485891a1b2e69d5" "0109b0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89cae5dcd32135fd5a443bcd7dd5f1c052c986f5512cccd54163df53fbd7a420a42a32a38827e0ccf6a303778ff3dfacb0cad8ceebd64e092bed2663427a091204f6b5f186f71a1bd5c534976aba8b313018197269d810f5bc6fe0ca03bfc9b05a1aec5ddc34855519f3d2d6837f1d859dbe9543e2b188e5cd19037aed96ecb53247ab126e58cee2fcddd50c78cd46453f19a6df767c110a1a59ceaf4090e1f49d3af460d539376d0e173f736b54a871843c2bfc7d18e60d1df6ada80f29ba6e0b5f33753fe162da11ed1c10dff3f0eb3d29c643f02e319cbae99911f3e9dbe163db41e7dbee7b5b92fe07794a1cbaadccf488b7ac1f41328bb02077ca0ad6d1dc4bfd92a072dad9567ddd98455915fb81bf4997a1bd78e4d960b1e944b56746520afd73cabe651625c060da92a499366ec0e81df8e73ab7a1ab6354a97c9807d3f329ce19242b4e16ec4eb65e49af4e4a9c0ed54464522c98b98a30c45b5acce7b76033953602ce26c358c78e1c8875d44e0bae41243089f5408a2a945a8fb408b2dd0b96d05ad14448c3334b2d5c27b73ef4131fb04b1ecda32422ab9620e3a114fe8ef11dd3856d095b8e6974eb53926b293134200336e72a92d665af622fcdec19fd072cfc493905a73dc5d856433293a24c60fdcbe9e7ea45b394e60d2fbfdef69f0d1e4fa430c297e81fa01498b3d9781c33ad80d8c5fa933e81f8bdd3d9afd0d47259970af52e5b01d636ea92d75fbd671b0342434573a83bf919c1d14a55b2f3e3c67e3cf809be5b50ce4bc19571a50c70c6fb86ed9951f98f03d3d6af1fcd5832a148859bb895a91fe9c905816e33df43b5c666b990baa1f97526620a1c87fdf518713222e618815023d5db0b0066dc81f3b13884545d6a510f5d11947e9a89c90baeee052cd3578d14999eac3eb2139e263bb8f3c6a8ccc5e935b2fced80b5803f7bb76a48ddc560758a6d7c6db681fef00a6377afd2d5aa2e77a60fb5bde864f4f180090ba2948e6429a8acbb3be6a5e240521a445b41da8d29eb5f1171dfd3248121f882aa0ea35c5bfa3b464d1088ee841f8c140564076d5ee342b20cde2218aa673c9c55fb3317696692e40d7895b1a8ecf6ecb9d308d72e0ef39bf97bdce8025d02faf27dda3b1cf99ccbaf628d090f195da8e2ddae82df934cfb7862f9d54beb2b2dafe38d5f9950128b387f2b0a6cfbc604c844175c642707ec35f3b27ba693211be15ff995b545ce1d2d16adcd5ca23eb4613bd1cf1c5d13ce9caeb9e154ca0eab40c01b11d6506a9a6a5b0b285ff553903691242b85ad2b5e5718d24b8c8853baf605c44a2156bfc4b734f2d4e1b3caf30299ef464e83a0bbec01ae74743657b63be4d9998a18d49d6a48c926c51c2b6347057eeee6ebe27b7f00d9403729e88734e9b6f65d3b6795fa278765376e8732853173b84d13bb985883794cf8485891a1b2e69d5"
@@ -264,6 +285,7 @@
"max_frame_data": 1048572, "max_frame_data": 1048572,
"snapshot": true, "snapshot": true,
"force_gated": false, "force_gated": false,
"control": false,
"gated": false, "gated": false,
"frames": [ "frames": [
"010ba0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a59e3d72b8892fa67302d87ec18c8cc4c994d83f57f0c339181f976623149c97875b81538c552b91afc51005991fa4c6d2fc758cbd5242ace3e9544d563cc01a765b6d92c1dbe6a539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab676ca576ae90cf2fe459e7372edf815e5d" "010ba0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a59e3d72b8892fa67302d87ec18c8cc4c994d83f57f0c339181f976623149c97875b81538c552b91afc51005991fa4c6d2fc758cbd5242ace3e9544d563cc01a765b6d92c1dbe6a539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab676ca576ae90cf2fe459e7372edf815e5d"
@@ -285,11 +307,37 @@
"max_frame_data": 1048572, "max_frame_data": 1048572,
"snapshot": false, "snapshot": false,
"force_gated": false, "force_gated": false,
"control": false,
"gated": false, "gated": false,
"frames": [ "frames": [
"0109d0d1d2d3d4d5d6d7d8d9dadbdcdddedf0000000000000410b7c763b22f261aed03d822e5fc9b5febf11d186b28855e37bb162cd659d962897e8eeb6212b1ea8d7f3c8c2f961ddb7afa2ed065dedad63bde32176671fb54a09756f0285f0a6842ba9dbd9ff235644cdec7241932d76cc918dc34254884668b65abb76aba2fcde3cd9f302268fd0eca75c5bd627b3e4e35ab40fbc0b895caafc2c5d3b3d3e44098064e17a4eb1895055bf9f7936c2a33021c7da0078528167b2c00f299814ef5bfdb6bbfe70d25731d200bcbb92af971eea0824667f896d6376e0aab89dd5a1228be6bccb9379dfdc79e7074d4d5f3c604b3c709348c35c92de47f30ec82073c9a8d935dd83e3ad300677e5e1261da074c4378f7b521a84934b3dc7664f7e26b6b9e0c6c52f2200172923ea3794bd93243829d26e532f2c0c50dde1a46d3ff87127ba1702d73a0323913523e8a8aabcca2c1315739e93bfc783773c186b7ff50c91e9dd18cdc38de9690499fd05adad02db38e1dbc58b12968f001ec162248cba9d84286b3f413f27b98243875aeab677bf6b740267e642598331534d1c0c5c03a2cbaec39677ab3c12069c93b617d286aa85268a42d9d181b0e2c1381b6a1d5f4c008b1ec849c33fe12c3f53e3a74f9ee1f262c47c8adcf60081b5444e4a91d88e99131e3d8067fab78615704d335e0a6881eff89a5053af201fb2d1eadc53ea18a32c1cd1a1043ae9074821e396788550d6a403f257515340c424a86d62622409127c7faae301b589c2e31da258b0e9552cff0b0ca986320e46a3f97649848faddf038cda376c0fb69ed7cda1e2abeb33cc1ee9cbaf187a2dc33a6d896c909897e3ce543b38478d4e97a0c7a78837abb0ecf06044bfc75e8bdcd5bc7d3d94b18a459cb8f7973ea7047407a9e755cda9b276fa0a45e1e1ed9dd1172a05d5684576305c7bd10e79b5aa621556215b6b662884f78274c5631261de9dedadb6bcada81a309d8792179dab25096ceedfc15fca3b3209b011075393df693fe6edc12cfab29a138ddae1cc0022a172461a420c4d0a60b7b056d82f44775a298e2322ab7106e5fa62378c902ae9aece2819de44c5e9ce46bb597a427e78f6bdc21b9e7bbe153fa4c030a13bf156ba506220a66f2df12b9e9c4aa574fe3cd231c910e3ada3416ed604ddd1debc3029ab903f1555108c02d36b3073c89b79c118c875e0c4f8af3bae2d0697e05efe7e5a7e7a9f360820f854b7835012e8913c91f5cca77dfa8bcef5f1384bd7bdf7767061b4c332b52020f1f8f0f36fa126c52f6c36d1689a5e99dde6b02d92834eece6eba94e997591714a0a692652539894d6caa1640d8ec3d28e699fc4f4f1fae62ddd40c46adfe891c2d6142f89fea0329c5b9504a9069a8aa29b85374dac5acfc5be7c7683ae7d98ce0826164c8d8b21f75f06e2c5648bad52d5878596cde0a948d9fdccccbd53ec3b1c3e3922d46f5736a5fe59b5ee5cabff0aea533d6" "0109d0d1d2d3d4d5d6d7d8d9dadbdcdddedf0000000000000410b7c763b22f261aed03d822e5fc9b5febf11d186b28855e37bb162cd659d962897e8eeb6212b1ea8d7f3c8c2f961ddb7afa2ed065dedad63bde32176671fb54a09756f0285f0a6842ba9dbd9ff235644cdec7241932d76cc918dc34254884668b65abb76aba2fcde3cd9f302268fd0eca75c5bd627b3e4e35ab40fbc0b895caafc2c5d3b3d3e44098064e17a4eb1895055bf9f7936c2a33021c7da0078528167b2c00f299814ef5bfdb6bbfe70d25731d200bcbb92af971eea0824667f896d6376e0aab89dd5a1228be6bccb9379dfdc79e7074d4d5f3c604b3c709348c35c92de47f30ec82073c9a8d935dd83e3ad300677e5e1261da074c4378f7b521a84934b3dc7664f7e26b6b9e0c6c52f2200172923ea3794bd93243829d26e532f2c0c50dde1a46d3ff87127ba1702d73a0323913523e8a8aabcca2c1315739e93bfc783773c186b7ff50c91e9dd18cdc38de9690499fd05adad02db38e1dbc58b12968f001ec162248cba9d84286b3f413f27b98243875aeab677bf6b740267e642598331534d1c0c5c03a2cbaec39677ab3c12069c93b617d286aa85268a42d9d181b0e2c1381b6a1d5f4c008b1ec849c33fe12c3f53e3a74f9ee1f262c47c8adcf60081b5444e4a91d88e99131e3d8067fab78615704d335e0a6881eff89a5053af201fb2d1eadc53ea18a32c1cd1a1043ae9074821e396788550d6a403f257515340c424a86d62622409127c7faae301b589c2e31da258b0e9552cff0b0ca986320e46a3f97649848faddf038cda376c0fb69ed7cda1e2abeb33cc1ee9cbaf187a2dc33a6d896c909897e3ce543b38478d4e97a0c7a78837abb0ecf06044bfc75e8bdcd5bc7d3d94b18a459cb8f7973ea7047407a9e755cda9b276fa0a45e1e1ed9dd1172a05d5684576305c7bd10e79b5aa621556215b6b662884f78274c5631261de9dedadb6bcada81a309d8792179dab25096ceedfc15fca3b3209b011075393df693fe6edc12cfab29a138ddae1cc0022a172461a420c4d0a60b7b056d82f44775a298e2322ab7106e5fa62378c902ae9aece2819de44c5e9ce46bb597a427e78f6bdc21b9e7bbe153fa4c030a13bf156ba506220a66f2df12b9e9c4aa574fe3cd231c910e3ada3416ed604ddd1debc3029ab903f1555108c02d36b3073c89b79c118c875e0c4f8af3bae2d0697e05efe7e5a7e7a9f360820f854b7835012e8913c91f5cca77dfa8bcef5f1384bd7bdf7767061b4c332b52020f1f8f0f36fa126c52f6c36d1689a5e99dde6b02d92834eece6eba94e997591714a0a692652539894d6caa1640d8ec3d28e699fc4f4f1fae62ddd40c46adfe891c2d6142f89fea0329c5b9504a9069a8aa29b85374dac5acfc5be7c7683ae7d98ce0826164c8d8b21f75f06e2c5648bad52d5878596cde0a948d9fdccccbd53ec3b1c3e3922d46f5736a5fe59b5ee5cabff0aea533d6"
] ]
}, },
{
"name": "file_in_thread_folder",
"item_id": "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf",
"meta": {
"v": 1,
"kind": "file",
"name": "readme.txt",
"mime": "text/plain",
"size": 11,
"ts": 1700000000000,
"dev": "Windows 11",
"thread_id": "01234567-89ab-cdef-0123-456789abcdef",
"folder_id": "fedcba98-7654-3210-fedc-ba9876543210"
},
"meta_json": "{\"v\":1,\"kind\":\"file\",\"name\":\"readme.txt\",\"mime\":\"text/plain\",\"size\":11,\"ts\":1700000000000,\"dev\":\"Windows 11\",\"thread_id\":\"01234567-89ab-cdef-0123-456789abcdef\",\"folder_id\":\"fedcba98-7654-3210-fedc-ba9876543210\"}",
"content": "7468726561642074657374",
"max_frame_data": 1048572,
"snapshot": false,
"force_gated": false,
"control": false,
"gated": false,
"frames": [
"0109d0d1d2d3d4d5d6d7d8d9dadbdcdddedf0000000000000410b7c763372fad1aed03d822e5fc9b5febf11d186b28855e37bb162cd659d962897e8eeb7e18a4ebce6e6a8c75ce1d9a31fa229f3ade949137cf65132560fe5bebd556af635606301da996a08caa643205cdca251e32d76cc918dc342548c93ec22ba3bd3cef7a9dd8c0f1544d1f8e2efb44e791400f563c50ca24a4a9dcb7f08df2f4e180e7d176af2b762ec58935f6613e9fdaa35d18002f28489630bd1177194f6497ffa362d7d9b407db827f7a1a790231e9df4f9d128cc1bb7e4acfa0e303433999b8ed77744dda08e1db56a4c5f0a84540e7e7c2f626ceb36146e954ad0d901a439882073c9a8d935dd83e3ad300677e5e1261da074c4378f7b521a84934b3dc7664f7e26b6b9e0c6c52f2200172923ea3794bd93243829d26e532f2c0c50dde1a46d3ff87127ba1702d73a0323913523e8a8aabcca2c1315739e93bfc783773c186b7ff50c91e9dd18cdc38de9690499fd05adad02db38e1dbc58b12968f001ec162248cba9d84286b3f413f27b98243875aeab677bf6b740267e642598331534d1c0c5c03a2cbaec39677ab3c12069c93b617d286aa85268a42d9d181b0e2c1381b6a1d5f4c008b1ec849c33fe12c3f53e3a74f9ee1f262c47c8adcf60081b5444e4a91d88e99131e3d8067fab78615704d335e0a6881eff89a5053af201fb2d1eadc53ea18a32c1cd1a1043ae9074821e396788550d6a403f257515340c424a86d62622409127c7faae301b589c2e31da258b0e9552cff0b0ca986320e46a3f97649848faddf038cda376c0fb69ed7cda1e2abeb33cc1ee9cbaf187a2dc33a6d896c909897e3ce543b38478d4e97a0c7a78837abb0ecf06044bfc75e8bdcd5bc7d3d94b18a459cb8f7973ea7047407a9e755cda9b276fa0a45e1e1ed9dd1172a05d5684576305c7bd10e79b5aa621556215b6b662884f78274c5631261de9dedadb6bcada81a309d8792179dab25096ceedfc15fca3b3209b011075393df693fe6edc12cfab29a138ddae1cc0022a172461a420c4d0a60b7b056d82f44775a298e2322ab7106e5fa62378c902ae9aece2819de44c5e9ce46bb597a427e78f6bdc21b9e7bbe153fa4c030a13bf156ba506220a66f2df12b9e9c4aa574fe3cd231c910e3ada3416ed604ddd1debc3029ab903f1555108c02d36b3073c89b79c118c875e0c4f8af3bae2d0697e05efe7e5a7e7a9f360820f854b7835012e8913c91f5cca77dfa8bcef5f1384bd7bdf7767061b4c332b52020f1f8f0f36fa126c52f6c36d1689a5e99dde6b02d92834eece6eba94e997591714a0a692652539894d6caa1640d8ec3d28e699fc4f4f1fae62ddd40c46adfe891c2d6142f89fea0329c5b9504a9069a8aa29b85374dac5acfc5be7c7683ae7d98ce0826164c8d8b21f75f06e2c5648bad52d5878596cde0a948d9fdccccbd53ec3b1c3e3922d93d89c6d34b8a4e0e896f8c5874856ee"
]
},
{ {
"name": "empty_text", "name": "empty_text",
"item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf", "item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
@@ -303,6 +351,7 @@
"max_frame_data": 1048572, "max_frame_data": 1048572,
"snapshot": false, "snapshot": false,
"force_gated": false, "force_gated": false,
"control": false,
"gated": false, "gated": false,
"frames": [ "frames": [
"0109b0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89cd15ddb32135fd5a443bcd7dd5f1c052c986f5512cccd54163df53fbd7a420a42a32a38827e0ccf6a303729dd599fbd2e97ae0835f507083af20f644fcfff456d16baa0c9990f03b9f7ce933b255489856701a17b1da35bc6fe0ca03bfc9b05a1aec5ddc34855519f3d2d6837f1d859dbe9543e2b188e5cd19037aed96ecb53247ab126e58cee2fcddd50c78cd46453f19a6df767c110a1a59ceaf4090e1f49d3af460d539376d0e173f736b54a871843c2bfc7d18e60d1df6ada80f29ba6e0b5f33753fe162da11ed1c10dff3f0eb3d29c643f02e319cbae99911f3e9dbe163db41e7dbee7b5b92fe07794a1cbaadccf488b7ac1f41328bb02077ca0ad6d1dc4bfd92a072dad9567ddd98455915fb81bf4997a1bd78e4d960b1e944b56746520afd73cabe651625c060da92a499366ec0e81df8e73ab7a1ab6354a97c9807d3f329ce19242b4e16ec4eb65e49af4e4a9c0ed54464522c98b98a30c45b5acce7b76033953602ce26c358c78e1c8875d44e0bae41243089f5408a2a945a8fb408b2dd0b96d05ad14448c3334b2d5c27b73ef4131fb04b1ecda32422ab9620e3a114fe8ef11dd3856d095b8e6974eb53926b293134200336e72a92d665af622fcdec19fd072cfc493905a73dc5d856433293a24c60fdcbe9e7ea45b394e60d2fbfdef69f0d1e4fa430c297e81fa01498b3d9781c33ad80d8c5fa933e81f8bdd3d9afd0d47259970af52e5b01d636ea92d75fbd671b0342434573a83bf919c1d14a55b2f3e3c67e3cf809be5b50ce4bc19571a50c70c6fb86ed9951f98f03d3d6af1fcd5832a148859bb895a91fe9c905816e33df43b5c666b990baa1f97526620a1c87fdf518713222e618815023d5db0b0066dc81f3b13884545d6a510f5d11947e9a89c90baeee052cd3578d14999eac3eb2139e263bb8f3c6a8ccc5e935b2fced80b5803f7bb76a48ddc560758a6d7c6db681fef00a6377afd2d5aa2e77a60fb5bde864f4f180090ba2948e6429a8acbb3be6a5e240521a445b41da8d29eb5f1171dfd3248121f882aa0ea35c5bfa3b464d1088ee841f8c140564076d5ee342b20cde2218aa673c9c55fb3317696692e40d7895b1a8ecf6ecb9d308d72e0ef39bf97bdce8025d02faf27dda3b1cf99ccbaf628d090f195da8e2ddae82df934cfb7862f9d54beb2b2dafe38d5f9950128b387f2b0a6cfbc604c844175c642707ec35f3b27ba693211be15ff995b545ce1d2d16adcd5ca23eb4613bd1cf1c5d13ce9caeb9e154ca0eab40c01b11d6506a9a6a5b0b285ff553903691242b85ad2b5e5718d24b8c8853baf605c44a2156bfc4b734f2d4e1b3caf30299ef464e83a0bbec01ae74743657b63be4d9998a18d49d6a48c926c51c2b6347057eeee6ebe27b7f00d9403729e88734e9b6f65d3b6795fa278765376e8732853173b84cfc631583cbee2e31f9a66775ccc580f" "0109b0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89cd15ddb32135fd5a443bcd7dd5f1c052c986f5512cccd54163df53fbd7a420a42a32a38827e0ccf6a303729dd599fbd2e97ae0835f507083af20f644fcfff456d16baa0c9990f03b9f7ce933b255489856701a17b1da35bc6fe0ca03bfc9b05a1aec5ddc34855519f3d2d6837f1d859dbe9543e2b188e5cd19037aed96ecb53247ab126e58cee2fcddd50c78cd46453f19a6df767c110a1a59ceaf4090e1f49d3af460d539376d0e173f736b54a871843c2bfc7d18e60d1df6ada80f29ba6e0b5f33753fe162da11ed1c10dff3f0eb3d29c643f02e319cbae99911f3e9dbe163db41e7dbee7b5b92fe07794a1cbaadccf488b7ac1f41328bb02077ca0ad6d1dc4bfd92a072dad9567ddd98455915fb81bf4997a1bd78e4d960b1e944b56746520afd73cabe651625c060da92a499366ec0e81df8e73ab7a1ab6354a97c9807d3f329ce19242b4e16ec4eb65e49af4e4a9c0ed54464522c98b98a30c45b5acce7b76033953602ce26c358c78e1c8875d44e0bae41243089f5408a2a945a8fb408b2dd0b96d05ad14448c3334b2d5c27b73ef4131fb04b1ecda32422ab9620e3a114fe8ef11dd3856d095b8e6974eb53926b293134200336e72a92d665af622fcdec19fd072cfc493905a73dc5d856433293a24c60fdcbe9e7ea45b394e60d2fbfdef69f0d1e4fa430c297e81fa01498b3d9781c33ad80d8c5fa933e81f8bdd3d9afd0d47259970af52e5b01d636ea92d75fbd671b0342434573a83bf919c1d14a55b2f3e3c67e3cf809be5b50ce4bc19571a50c70c6fb86ed9951f98f03d3d6af1fcd5832a148859bb895a91fe9c905816e33df43b5c666b990baa1f97526620a1c87fdf518713222e618815023d5db0b0066dc81f3b13884545d6a510f5d11947e9a89c90baeee052cd3578d14999eac3eb2139e263bb8f3c6a8ccc5e935b2fced80b5803f7bb76a48ddc560758a6d7c6db681fef00a6377afd2d5aa2e77a60fb5bde864f4f180090ba2948e6429a8acbb3be6a5e240521a445b41da8d29eb5f1171dfd3248121f882aa0ea35c5bfa3b464d1088ee841f8c140564076d5ee342b20cde2218aa673c9c55fb3317696692e40d7895b1a8ecf6ecb9d308d72e0ef39bf97bdce8025d02faf27dda3b1cf99ccbaf628d090f195da8e2ddae82df934cfb7862f9d54beb2b2dafe38d5f9950128b387f2b0a6cfbc604c844175c642707ec35f3b27ba693211be15ff995b545ce1d2d16adcd5ca23eb4613bd1cf1c5d13ce9caeb9e154ca0eab40c01b11d6506a9a6a5b0b285ff553903691242b85ad2b5e5718d24b8c8853baf605c44a2156bfc4b734f2d4e1b3caf30299ef464e83a0bbec01ae74743657b63be4d9998a18d49d6a48c926c51c2b6347057eeee6ebe27b7f00d9403729e88734e9b6f65d3b6795fa278765376e8732853173b84cfc631583cbee2e31f9a66775ccc580f"
@@ -324,6 +373,7 @@
"max_frame_data": 1000, "max_frame_data": 1000,
"snapshot": false, "snapshot": false,
"force_gated": false, "force_gated": false,
"control": false,
"gated": true, "gated": true,
"frames": [ "frames": [
"010cd0d1d2d3d4d5d6d7d8d9dadbdcdddedf0000000000000410b7c7603f2f1b1aed03d822e5fc9b5febf11d186b28855e37bb162cd659d962897e8eeb6d53a7e6cd2968da60d352d331ad69932f8cc2da20cb690e657ebd55e1cf11f76c561b381deac1b391a4352f0c8ad22e1b37d76cd50a98773742c839de39f7e87afd708fbf99af4d2269ff0dce70c3ba6a72344539a64ef4d0a987d9bbd7d3c4abcafe5b841b500884ca3ab6217edfd0bb4500182e31538f37b41a254f1936c5a1b874ce83e65580a74c673059654d8cf163b33aa2edcc0937a9c485633b5cfcd184004974e33593d956ff9ea3fb1613bcbc99ad68dea96644fd47ba5991094794fb7d47e6f0ed2258bfb85084e2f8d99ae8508cc0cef67825b03adaa0264ae1fc6e78f0f70392f3f25382a2d6379804d1e27399ef2f33895583407371b868adfe6a453caec61fcfedb262f1fdd694f9424361076e0cff98e938e92face2a5165e6e258b15c3430e6c3dda3d7275af7838b3303bc15e60f24ca943da9c05f71beedbb2cba8da4182b6f214fa72922f3478a0a4776ae4a4543368733d81290e28ccdedae01b0e99c81c415d9be80a42e5164f52185b9a615c911baa202234172fbc889e95b5824bf5a9c2db7bb75888b973743ba9bf4d7578129efa973952400819baf67de98bf25586be6117c2120a3b69bd5a90d7fa6d8bfcd372428b7b805163d3babe2008b145489c97cb271aff0e93b7e818c49ff9d4aab3e28dad96d9d61b48b982e13384635f0897b3f136859d778b24be24e07c44057c2fdb995ed1832ada27883b1f33fc0865b10832a326b017d0e56e62ee123a496c265f7b06e87a054816e9689cdf01a65563903d0391e097966c8a4afc3cf2f1bd0b8d1147cd5ac5d0dd4e1ea351c285727fe77e48506b8c6648cf8d3077b9be450203c7c23153827e72a171442dee973bcbb6748911645026828354bf77411d776a0c1822a99f98982f8f9cc6eb4092326d3494fd00c79cbea840aaf4eb79c15a4c28676296f29c0db877a9cc41c852b6c271ae6d5a665612d055b2a7de72017e11ff8a38f5231a61b6af3197e6d62ca8f4448c210a7d70120971dac9047df12e0b39b9782fca7e821d421d46fb53e6a8a6be11badb14b491bed34468aa00537f16eaf15c0de2de52caff1cf3de24aa8611d0250cd24b6bd0258087df18f4ec68dbe157687cf06e64bae9a96d1b53083b8593eb00171455145d069675f77cb17834032d8d16cf1854c37dd4a4b1e1500395af68cb627111035529304e1f1100af2e14d9364974d1eb443ca289c4b3f15b33eb1b00dbf85982add3ac652a2a9fe6d327667dcc0b2be25f0a93a07066a9c9ad1d1c4bfb348a8c551cf6a2d4427201239afc8e664fa2d13920fb05c5c446c82206a9b1d98a2c9fbe12419ba4f29f02e0e64b5c3799f278e7a6ddc4375ba25878596cde0a948d9fdccccbd53ec3b1c3e3922dd672eb64712415b3dbfc6596618f5bb1", "010cd0d1d2d3d4d5d6d7d8d9dadbdcdddedf0000000000000410b7c7603f2f1b1aed03d822e5fc9b5febf11d186b28855e37bb162cd659d962897e8eeb6d53a7e6cd2968da60d352d331ad69932f8cc2da20cb690e657ebd55e1cf11f76c561b381deac1b391a4352f0c8ad22e1b37d76cd50a98773742c839de39f7e87afd708fbf99af4d2269ff0dce70c3ba6a72344539a64ef4d0a987d9bbd7d3c4abcafe5b841b500884ca3ab6217edfd0bb4500182e31538f37b41a254f1936c5a1b874ce83e65580a74c673059654d8cf163b33aa2edcc0937a9c485633b5cfcd184004974e33593d956ff9ea3fb1613bcbc99ad68dea96644fd47ba5991094794fb7d47e6f0ed2258bfb85084e2f8d99ae8508cc0cef67825b03adaa0264ae1fc6e78f0f70392f3f25382a2d6379804d1e27399ef2f33895583407371b868adfe6a453caec61fcfedb262f1fdd694f9424361076e0cff98e938e92face2a5165e6e258b15c3430e6c3dda3d7275af7838b3303bc15e60f24ca943da9c05f71beedbb2cba8da4182b6f214fa72922f3478a0a4776ae4a4543368733d81290e28ccdedae01b0e99c81c415d9be80a42e5164f52185b9a615c911baa202234172fbc889e95b5824bf5a9c2db7bb75888b973743ba9bf4d7578129efa973952400819baf67de98bf25586be6117c2120a3b69bd5a90d7fa6d8bfcd372428b7b805163d3babe2008b145489c97cb271aff0e93b7e818c49ff9d4aab3e28dad96d9d61b48b982e13384635f0897b3f136859d778b24be24e07c44057c2fdb995ed1832ada27883b1f33fc0865b10832a326b017d0e56e62ee123a496c265f7b06e87a054816e9689cdf01a65563903d0391e097966c8a4afc3cf2f1bd0b8d1147cd5ac5d0dd4e1ea351c285727fe77e48506b8c6648cf8d3077b9be450203c7c23153827e72a171442dee973bcbb6748911645026828354bf77411d776a0c1822a99f98982f8f9cc6eb4092326d3494fd00c79cbea840aaf4eb79c15a4c28676296f29c0db877a9cc41c852b6c271ae6d5a665612d055b2a7de72017e11ff8a38f5231a61b6af3197e6d62ca8f4448c210a7d70120971dac9047df12e0b39b9782fca7e821d421d46fb53e6a8a6be11badb14b491bed34468aa00537f16eaf15c0de2de52caff1cf3de24aa8611d0250cd24b6bd0258087df18f4ec68dbe157687cf06e64bae9a96d1b53083b8593eb00171455145d069675f77cb17834032d8d16cf1854c37dd4a4b1e1500395af68cb627111035529304e1f1100af2e14d9364974d1eb443ca289c4b3f15b33eb1b00dbf85982add3ac652a2a9fe6d327667dcc0b2be25f0a93a07066a9c9ad1d1c4bfb348a8c551cf6a2d4427201239afc8e664fa2d13920fb05c5c446c82206a9b1d98a2c9fbe12419ba4f29f02e0e64b5c3799f278e7a6ddc4375ba25878596cde0a948d9fdccccbd53ec3b1c3e3922dd672eb64712415b3dbfc6596618f5bb1",
@@ -345,6 +395,7 @@
"max_frame_data": 1000, "max_frame_data": 1000,
"snapshot": false, "snapshot": false,
"force_gated": false, "force_gated": false,
"control": false,
"gated": false, "gated": false,
"frames": [ "frames": [
"0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee49f7e3cc2b8892fa67302d87ec18c8cc4c994d8fef7c07339181e3642d6e5ac46a27aa092a8111ab10fd564d1283be143872924ac1c73d4eba52bf61fd06e236f25b8afa123df702458b5567c3ac27d5835623394e87e11bcdb74dad9329d88ca850c9d29627417c6fa0a872582a6dfafbed838ffaee3d5098774eaaa2eede5a6c3bf8d6124e168cd86cdd40e13528699100d8e6d711f5c381621c2211c6f70b2a68fd60bc692e559f51c51ac1043b70f45801fdb1f8d86d1dc75ca54a7b6df9fb8728057308d0d9ad641ded456a80fc9f6d618694ca933ce93e43d16ab46c8c0351ffb65885f6c1ab8bc69194278c3c158dd8faf1189b283da786f3d48b6635cd96ff860ab3331dd50783a958173e0535ec6a44b9688684ea3a9bd5e7c7be84e89e71710a64c16340bc45cde109328446c6924354f165f92d8d5e98fe65e9b7714118b735c7d2b916f012570eedc69af9420e2ad04bd2f39a64602ec2e5364b27261d1e54e8b0b64894ec1828294169fb8b098aa27a27c22c7f941d5af4bd57c7de5cad5276739f457057e19633aa0809153fefab5bcd0e83a6f435905a96a61c52ca082427d51c8f24a8498d086bbc9803c74f9e0f146821f921d5ad786119fd1d387a6552d19d54265ed3b924add48344b7a2122a92ba9125c3f78cd2b53198c7bc191e65e158598d75f57326cb00144b7a14a85864b566e659615e43faedddce2d35492fe01711ec183492e1283f7366b4a6857bfe9110871e70518d7c5a51aadd42ec886318d9bde49222155dea2c40bda0e0e31dee0067630d5d250b1bfa9c17fe155947774b29a175d9fbfb1e9f21abf32d8e12436e8de907afa3a8f16adf40c0ec3d939185ad3ed5a815a87f046604f54590c2c866fc5d1802c13b1d12234dbc92e14e68349c111a20e7bf39d6291aee856ff3c29e86342d67b09d14e084f5bcb887e468ecf3f2c8f1f46d0f3779022db8aaee0ecac0789095406e76e639c7cc1ac949d219701e676cd4c02e5793a7fa7b7b29a56730abc8ed7a52d80425c96e1cd57ed2ff2902f532adecbb18dbaa21ae03b07e82bbc57b75762444bbf6ca0f7b3eb5e0bc2cf0cccfc3c232d6bcfb473303d80c1d5348136c79a34a68ee646f442734a0e52f5c07b78db4ef77b406ba19ce2a278dd0b5b40f0c2ca68360aaf345f42848a0aeaea28e7893ed126c91415c30d54c0ea110f79ffe525abbf9e548f49a05e4f95d9f8ffd38c0481ac682c4c042a0ac5b1716b853f8b2af5fb6f2358dffe9df5300e1a49bfcb8eb31dada59ae98d4c66d9dea53b8f1ec8af980ad28564e30799e276f71c581a9e0cd0d7e2f2b84837cdeb3b1c023e75405b8a33a1e35aa471ef7847013d0f4c7404a46c6a11e96d03d889fdab81462c34e849240de96805d312e566393b33914b30dbb37f32cb4cfbf9f2af171ab675ad6bd81fb6f247b08a9250f920b4660" "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee49f7e3cc2b8892fa67302d87ec18c8cc4c994d8fef7c07339181e3642d6e5ac46a27aa092a8111ab10fd564d1283be143872924ac1c73d4eba52bf61fd06e236f25b8afa123df702458b5567c3ac27d5835623394e87e11bcdb74dad9329d88ca850c9d29627417c6fa0a872582a6dfafbed838ffaee3d5098774eaaa2eede5a6c3bf8d6124e168cd86cdd40e13528699100d8e6d711f5c381621c2211c6f70b2a68fd60bc692e559f51c51ac1043b70f45801fdb1f8d86d1dc75ca54a7b6df9fb8728057308d0d9ad641ded456a80fc9f6d618694ca933ce93e43d16ab46c8c0351ffb65885f6c1ab8bc69194278c3c158dd8faf1189b283da786f3d48b6635cd96ff860ab3331dd50783a958173e0535ec6a44b9688684ea3a9bd5e7c7be84e89e71710a64c16340bc45cde109328446c6924354f165f92d8d5e98fe65e9b7714118b735c7d2b916f012570eedc69af9420e2ad04bd2f39a64602ec2e5364b27261d1e54e8b0b64894ec1828294169fb8b098aa27a27c22c7f941d5af4bd57c7de5cad5276739f457057e19633aa0809153fefab5bcd0e83a6f435905a96a61c52ca082427d51c8f24a8498d086bbc9803c74f9e0f146821f921d5ad786119fd1d387a6552d19d54265ed3b924add48344b7a2122a92ba9125c3f78cd2b53198c7bc191e65e158598d75f57326cb00144b7a14a85864b566e659615e43faedddce2d35492fe01711ec183492e1283f7366b4a6857bfe9110871e70518d7c5a51aadd42ec886318d9bde49222155dea2c40bda0e0e31dee0067630d5d250b1bfa9c17fe155947774b29a175d9fbfb1e9f21abf32d8e12436e8de907afa3a8f16adf40c0ec3d939185ad3ed5a815a87f046604f54590c2c866fc5d1802c13b1d12234dbc92e14e68349c111a20e7bf39d6291aee856ff3c29e86342d67b09d14e084f5bcb887e468ecf3f2c8f1f46d0f3779022db8aaee0ecac0789095406e76e639c7cc1ac949d219701e676cd4c02e5793a7fa7b7b29a56730abc8ed7a52d80425c96e1cd57ed2ff2902f532adecbb18dbaa21ae03b07e82bbc57b75762444bbf6ca0f7b3eb5e0bc2cf0cccfc3c232d6bcfb473303d80c1d5348136c79a34a68ee646f442734a0e52f5c07b78db4ef77b406ba19ce2a278dd0b5b40f0c2ca68360aaf345f42848a0aeaea28e7893ed126c91415c30d54c0ea110f79ffe525abbf9e548f49a05e4f95d9f8ffd38c0481ac682c4c042a0ac5b1716b853f8b2af5fb6f2358dffe9df5300e1a49bfcb8eb31dada59ae98d4c66d9dea53b8f1ec8af980ad28564e30799e276f71c581a9e0cd0d7e2f2b84837cdeb3b1c023e75405b8a33a1e35aa471ef7847013d0f4c7404a46c6a11e96d03d889fdab81462c34e849240de96805d312e566393b33914b30dbb37f32cb4cfbf9f2af171ab675ad6bd81fb6f247b08a9250f920b4660"
@@ -364,6 +415,7 @@
"max_frame_data": 1000, "max_frame_data": 1000,
"snapshot": false, "snapshot": false,
"force_gated": false, "force_gated": false,
"control": false,
"gated": true, "gated": true,
"frames": [ "frames": [
"010ca0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee49f7e3cc2b8892fa67302d87ec18c8cc4c994d8fef7c07339181e3642d6e5ac46a27aa092a8111ab10fd564d1283be143872924ac1c73d4eba52bf61fd06e236f25b8afa123df702458b5567c3ac27d5835623394e87e11bcdb74dad9329d88ca850c9d29627417c6fa0a872582a6dfafbed838ffaee3d5098774eaaa2eede5a6c3bf8d6124e168cd86cdd40e13528699100d8e6d711f5c381621c2211c6f70b2a68fd60bc692e559f51c51ac1043b70f45801fdb1f8d86d1dc75ca54a7b6df9fb8728057308d0d9ad641ded456a80fc9f6d618694ca933ce93e43d16ab46c8c0351ffb65885f6c1ab8bc69194278c3c158dd8faf1189b283da786f3d48b6635cd96ff860ab3331dd50783a958173e0535ec6a44b9688684ea3a9bd5e7c7be84e89e71710a64c16340bc45cde109328446c6924354f165f92d8d5e98fe65e9b7714118b735c7d2b916f012570eedc69af9420e2ad04bd2f39a64602ec2e5364b27261d1e54e8b0b64894ec1828294169fb8b098aa27a27c22c7f941d5af4bd57c7de5cad5276739f457057e19633aa0809153fefab5bcd0e83a6f435905a96a61c52ca082427d51c8f24a8498d086bbc9803c74f9e0f146821f921d5ad786119fd1d387a6552d19d54265ed3b924add48344b7a2122a92ba9125c3f78cd2b53198c7bc191e65e158598d75f57326cb00144b7a14a85864b566e659615e43faedddce2d35492fe01711ec183492e1283f7366b4a6857bfe9110871e70518d7c5a51aadd42ec886318d9bde49222155dea2c40bda0e0e31dee0067630d5d250b1bfa9c17fe155947774b29a175d9fbfb1e9f21abf32d8e12436e8de907afa3a8f16adf40c0ec3d939185ad3ed5a815a87f046604f54590c2c866fc5d1802c13b1d12234dbc92e14e68349c111a20e7bf39d6291aee856ff3c29e86342d67b09d14e084f5bcb887e468ecf3f2c8f1f46d0f3779022db8aaee0ecac0789095406e76e639c7cc1ac949d219701e676cd4c02e5793a7fa7b7b29a56730abc8ed7a52d80425c96e1cd57ed2ff2902f532adecbb18dbaa21ae03b07e82bbc57b75762444bbf6ca0f7b3eb5e0bc2cf0cccfc3c232d6bcfb473303d80c1d5348136c79a34a68ee646f442734a0e52f5c07b78db4ef77b406ba19ce2a278dd0b5b40f0c2ca68360aaf345f42848a0aeaea28e7893ed126c91415c30d54c0ea110f79ffe525abbf9e548f49a05e4f95d9f8ffd38c0481ac682c4c042a0ac5b1716b853f8b2af5fb6f2358dffe9df5300e1a49bfcb8eb31dada59ae98d4c66d9dea53b8f1ec8af980ad28564e30799e276f71c581a9e0cd0d7e2f2b84837cdeb3b1c023e75405b8a33a1e35aa471ef7847013d0f4c7404a46c6a11e96d03d889fdab81462c34e849240de96805d312e566393b33914b30dbb37f32cb4cfbf9f2af171ab6752de780d3621900034a5e664d343c68d", "010ca0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee49f7e3cc2b8892fa67302d87ec18c8cc4c994d8fef7c07339181e3642d6e5ac46a27aa092a8111ab10fd564d1283be143872924ac1c73d4eba52bf61fd06e236f25b8afa123df702458b5567c3ac27d5835623394e87e11bcdb74dad9329d88ca850c9d29627417c6fa0a872582a6dfafbed838ffaee3d5098774eaaa2eede5a6c3bf8d6124e168cd86cdd40e13528699100d8e6d711f5c381621c2211c6f70b2a68fd60bc692e559f51c51ac1043b70f45801fdb1f8d86d1dc75ca54a7b6df9fb8728057308d0d9ad641ded456a80fc9f6d618694ca933ce93e43d16ab46c8c0351ffb65885f6c1ab8bc69194278c3c158dd8faf1189b283da786f3d48b6635cd96ff860ab3331dd50783a958173e0535ec6a44b9688684ea3a9bd5e7c7be84e89e71710a64c16340bc45cde109328446c6924354f165f92d8d5e98fe65e9b7714118b735c7d2b916f012570eedc69af9420e2ad04bd2f39a64602ec2e5364b27261d1e54e8b0b64894ec1828294169fb8b098aa27a27c22c7f941d5af4bd57c7de5cad5276739f457057e19633aa0809153fefab5bcd0e83a6f435905a96a61c52ca082427d51c8f24a8498d086bbc9803c74f9e0f146821f921d5ad786119fd1d387a6552d19d54265ed3b924add48344b7a2122a92ba9125c3f78cd2b53198c7bc191e65e158598d75f57326cb00144b7a14a85864b566e659615e43faedddce2d35492fe01711ec183492e1283f7366b4a6857bfe9110871e70518d7c5a51aadd42ec886318d9bde49222155dea2c40bda0e0e31dee0067630d5d250b1bfa9c17fe155947774b29a175d9fbfb1e9f21abf32d8e12436e8de907afa3a8f16adf40c0ec3d939185ad3ed5a815a87f046604f54590c2c866fc5d1802c13b1d12234dbc92e14e68349c111a20e7bf39d6291aee856ff3c29e86342d67b09d14e084f5bcb887e468ecf3f2c8f1f46d0f3779022db8aaee0ecac0789095406e76e639c7cc1ac949d219701e676cd4c02e5793a7fa7b7b29a56730abc8ed7a52d80425c96e1cd57ed2ff2902f532adecbb18dbaa21ae03b07e82bbc57b75762444bbf6ca0f7b3eb5e0bc2cf0cccfc3c232d6bcfb473303d80c1d5348136c79a34a68ee646f442734a0e52f5c07b78db4ef77b406ba19ce2a278dd0b5b40f0c2ca68360aaf345f42848a0aeaea28e7893ed126c91415c30d54c0ea110f79ffe525abbf9e548f49a05e4f95d9f8ffd38c0481ac682c4c042a0ac5b1716b853f8b2af5fb6f2358dffe9df5300e1a49bfcb8eb31dada59ae98d4c66d9dea53b8f1ec8af980ad28564e30799e276f71c581a9e0cd0d7e2f2b84837cdeb3b1c023e75405b8a33a1e35aa471ef7847013d0f4c7404a46c6a11e96d03d889fdab81462c34e849240de96805d312e566393b33914b30dbb37f32cb4cfbf9f2af171ab6752de780d3621900034a5e664d343c68d",
@@ -384,6 +436,7 @@
"max_frame_data": 1048572, "max_frame_data": 1048572,
"snapshot": false, "snapshot": false,
"force_gated": true, "force_gated": true,
"control": false,
"gated": true, "gated": true,
"frames": [ "frames": [
"010db0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89cbf5dc432135fd5a443bcd7dd5f1c052c986f471ed8dc54163def2df225510750f0347cca3a1ed378747476e768a88d1ea79e3805c537380ac272032ebb9a214d65d7c1a5f50f03b9f7ce933b255489856701a17b1da35bc6fe0ca03bfc9b05a1aec5ddc34855519f3d2d6837f1d859dbe9543e2b188e5cd19037aed96ecb53247ab126e58cee2fcddd50c78cd46453f19a6df767c110a1a59ceaf4090e1f49d3af460d539376d0e173f736b54a871843c2bfc7d18e60d1df6ada80f29ba6e0b5f33753fe162da11ed1c10dff3f0eb3d29c643f02e319cbae99911f3e9dbe163db41e7dbee7b5b92fe07794a1cbaadccf488b7ac1f41328bb02077ca0ad6d1dc4bfd92a072dad9567ddd98455915fb81bf4997a1bd78e4d960b1e944b56746520afd73cabe651625c060da92a499366ec0e81df8e73ab7a1ab6354a97c9807d3f329ce19242b4e16ec4eb65e49af4e4a9c0ed54464522c98b98a30c45b5acce7b76033953602ce26c358c78e1c8875d44e0bae41243089f5408a2a945a8fb408b2dd0b96d05ad14448c3334b2d5c27b73ef4131fb04b1ecda32422ab9620e3a114fe8ef11dd3856d095b8e6974eb53926b293134200336e72a92d665af622fcdec19fd072cfc493905a73dc5d856433293a24c60fdcbe9e7ea45b394e60d2fbfdef69f0d1e4fa430c297e81fa01498b3d9781c33ad80d8c5fa933e81f8bdd3d9afd0d47259970af52e5b01d636ea92d75fbd671b0342434573a83bf919c1d14a55b2f3e3c67e3cf809be5b50ce4bc19571a50c70c6fb86ed9951f98f03d3d6af1fcd5832a148859bb895a91fe9c905816e33df43b5c666b990baa1f97526620a1c87fdf518713222e618815023d5db0b0066dc81f3b13884545d6a510f5d11947e9a89c90baeee052cd3578d14999eac3eb2139e263bb8f3c6a8ccc5e935b2fced80b5803f7bb76a48ddc560758a6d7c6db681fef00a6377afd2d5aa2e77a60fb5bde864f4f180090ba2948e6429a8acbb3be6a5e240521a445b41da8d29eb5f1171dfd3248121f882aa0ea35c5bfa3b464d1088ee841f8c140564076d5ee342b20cde2218aa673c9c55fb3317696692e40d7895b1a8ecf6ecb9d308d72e0ef39bf97bdce8025d02faf27dda3b1cf99ccbaf628d090f195da8e2ddae82df934cfb7862f9d54beb2b2dafe38d5f9950128b387f2b0a6cfbc604c844175c642707ec35f3b27ba693211be15ff995b545ce1d2d16adcd5ca23eb4613bd1cf1c5d13ce9caeb9e154ca0eab40c01b11d6506a9a6a5b0b285ff553903691242b85ad2b5e5718d24b8c8853baf605c44a2156bfc4b734f2d4e1b3caf30299ef464e83a0bbec01ae74743657b63be4d9998a18d49d6a48c926c51c2b6347057eeee6ebe27b7f00d9403729e88734e9b6f65d3b6795fa278765376e8732853173b8443f6f752e2e1fac46a20c4f005725253" "010db0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89cbf5dc432135fd5a443bcd7dd5f1c052c986f471ed8dc54163def2df225510750f0347cca3a1ed378747476e768a88d1ea79e3805c537380ac272032ebb9a214d65d7c1a5f50f03b9f7ce933b255489856701a17b1da35bc6fe0ca03bfc9b05a1aec5ddc34855519f3d2d6837f1d859dbe9543e2b188e5cd19037aed96ecb53247ab126e58cee2fcddd50c78cd46453f19a6df767c110a1a59ceaf4090e1f49d3af460d539376d0e173f736b54a871843c2bfc7d18e60d1df6ada80f29ba6e0b5f33753fe162da11ed1c10dff3f0eb3d29c643f02e319cbae99911f3e9dbe163db41e7dbee7b5b92fe07794a1cbaadccf488b7ac1f41328bb02077ca0ad6d1dc4bfd92a072dad9567ddd98455915fb81bf4997a1bd78e4d960b1e944b56746520afd73cabe651625c060da92a499366ec0e81df8e73ab7a1ab6354a97c9807d3f329ce19242b4e16ec4eb65e49af4e4a9c0ed54464522c98b98a30c45b5acce7b76033953602ce26c358c78e1c8875d44e0bae41243089f5408a2a945a8fb408b2dd0b96d05ad14448c3334b2d5c27b73ef4131fb04b1ecda32422ab9620e3a114fe8ef11dd3856d095b8e6974eb53926b293134200336e72a92d665af622fcdec19fd072cfc493905a73dc5d856433293a24c60fdcbe9e7ea45b394e60d2fbfdef69f0d1e4fa430c297e81fa01498b3d9781c33ad80d8c5fa933e81f8bdd3d9afd0d47259970af52e5b01d636ea92d75fbd671b0342434573a83bf919c1d14a55b2f3e3c67e3cf809be5b50ce4bc19571a50c70c6fb86ed9951f98f03d3d6af1fcd5832a148859bb895a91fe9c905816e33df43b5c666b990baa1f97526620a1c87fdf518713222e618815023d5db0b0066dc81f3b13884545d6a510f5d11947e9a89c90baeee052cd3578d14999eac3eb2139e263bb8f3c6a8ccc5e935b2fced80b5803f7bb76a48ddc560758a6d7c6db681fef00a6377afd2d5aa2e77a60fb5bde864f4f180090ba2948e6429a8acbb3be6a5e240521a445b41da8d29eb5f1171dfd3248121f882aa0ea35c5bfa3b464d1088ee841f8c140564076d5ee342b20cde2218aa673c9c55fb3317696692e40d7895b1a8ecf6ecb9d308d72e0ef39bf97bdce8025d02faf27dda3b1cf99ccbaf628d090f195da8e2ddae82df934cfb7862f9d54beb2b2dafe38d5f9950128b387f2b0a6cfbc604c844175c642707ec35f3b27ba693211be15ff995b545ce1d2d16adcd5ca23eb4613bd1cf1c5d13ce9caeb9e154ca0eab40c01b11d6506a9a6a5b0b285ff553903691242b85ad2b5e5718d24b8c8853baf605c44a2156bfc4b734f2d4e1b3caf30299ef464e83a0bbec01ae74743657b63be4d9998a18d49d6a48c926c51c2b6347057eeee6ebe27b7f00d9403729e88734e9b6f65d3b6795fa278765376e8732853173b8443f6f752e2e1fac46a20c4f005725253"
@@ -650,7 +703,7 @@
}, },
{ {
"name": "reserved_flag_bit", "name": "reserved_flag_bit",
"envelope": "0119a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553", "envelope": "0129a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553",
"expect": "bad_envelope" "expect": "bad_envelope"
}, },
{ {
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';
import { accountApi } from '@/core-bridge/account';
import { useAccount } from '@/store/account';
import { useChannels } from '@/store/channels';
import { AccountScreen } from '@/features/home/AccountScreen';
import { MissingAccess } from '@/features/scan/JoinScreen';
import { Button } from '@/ui/Button';
import styles from '@/features/scan/JoinScreen.module.css';
export function AccountChannelAccess({ id, onHome }: { id: string; onHome: () => void }) {
const username = useAccount((s) => s.username);
const [invited, setInvited] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let active = true;
setInvited(false); setError('');
if (username) void accountApi.invitations().then((rows) => {
if (active) setInvited(rows.some((row) => row.channel_id === id));
}).catch(() => { if (active) setError('邀请读取失败,请重试同步。'); });
return () => { active = false; };
}, [id, username]);
async function accept() {
setBusy(true); setError('');
try { await accountApi.acceptInvitation(id); await useChannels.getState().refresh(); }
catch (cause) { setError(cause instanceof Error ? cause.message : '加入失败'); }
finally { setBusy(false); }
}
if (!username) return <AccountScreen />;
if (!invited) return <MissingAccess onHome={onHome} />;
return <div className={styles.page}><main className={styles.card}><h1 className={styles.title}>空间邀请</h1><p className={styles.dim}>接受邀请后,使用你的账号密钥自动解锁此空间,无需复制密钥链接。</p>
<Button variant="primary" disabled={busy} onClick={() => { void accept(); }}>{busy ? '加入中…' : '接受并加入空间'}</Button>
<Button variant="ghost" onClick={onHome}>回到首页</Button>
{error ? <p role="alert">{error}</p> : null}</main></div>;
}
+18 -7
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Home } from '@/features/home/Home'; import { Home } from '@/features/home/Home';
import { JoinScreen, MissingAccess } from '@/features/scan/JoinScreen'; import { JoinScreen } from '@/features/scan/JoinScreen';
import { AccountChannelAccess } from './AccountChannelAccess';
import { useChannels } from '@/store/channels'; import { useChannels } from '@/store/channels';
import { useShares } from '@/store/shares'; import { useShares } from '@/store/shares';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/workspace';
@@ -8,6 +9,8 @@ import { ChannelScreen } from './ChannelScreen';
import { ShareScreen } from './ShareScreen'; import { ShareScreen } from './ShareScreen';
import { channelPath, navigate, useRoute } from './navigation'; import { channelPath, navigate, useRoute } from './navigation';
import { Splash } from './Splash'; import { Splash } from './Splash';
import { AccountScreen } from '@/features/home/AccountScreen';
import { useAccount } from '@/store/account';
const SEEN_KEY = 'wd.splash'; const SEEN_KEY = 'wd.splash';
@@ -20,6 +23,13 @@ function splashSeen(): boolean {
} }
export function App() { export function App() {
const username = useAccount((s) => s.username);
const accountHydrated = useAccount((s) => s.hydrated);
const refreshAccount = useAccount((s) => s.refresh);
useEffect(() => {
const timer = window.setInterval(() => { void refreshAccount(); }, 5000);
return () => window.clearInterval(timer);
}, [refreshAccount]);
const route = useRoute(); const route = useRoute();
const list = useChannels((s) => s.list); const list = useChannels((s) => s.list);
const channelsHydrated = useChannels((s) => s.hydrated); const channelsHydrated = useChannels((s) => s.hydrated);
@@ -31,12 +41,12 @@ export function App() {
const [splash, setSplash] = useState(() => !splashSeen()); const [splash, setSplash] = useState(() => !splashSeen());
const saved = route.name === 'channel' && list.some((c) => c.id === route.id); const saved = route.name === 'channel' && list.some((c) => c.id === route.id);
// 只有打开已保存的频道才需要等数据;首页与加入页没有加载过程 // 只有打开已保存的空间才需要等数据;首页与加入页没有加载过程
const waitingOnSavedChannel = route.name === 'channel' && !pendingLink && !channelsHydrated; const waitingOnSavedChannel = route.name === 'channel' && !pendingLink && !channelsHydrated;
const waitingOnSavedShare = route.name === 'share' && pendingShareLink?.id !== route.id && !sharesHydrated; const waitingOnSavedShare = route.name === 'share' && pendingShareLink?.id !== route.id && !sharesHydrated;
const ready = route.name === 'home' || (route.name === 'channel' const ready = accountHydrated && (route.name === 'home' || (route.name === 'channel'
? pendingLink?.id === route.id || (channelsHydrated && (!saved || wsReady)) ? pendingLink?.id === route.id || (channelsHydrated && (!saved || wsReady))
: pendingShareLink?.id === route.id || sharesHydrated); : pendingShareLink?.id === route.id || sharesHydrated));
function onSplashDone() { function onSplashDone() {
try { try {
@@ -48,7 +58,8 @@ export function App() {
} }
let screen; let screen;
if (route.name === 'home') screen = <Home />; if (!accountHydrated) screen = null;
else if (route.name === 'home') screen = username ? <Home /> : <AccountScreen />;
else if (route.name === 'share' && waitingOnSavedShare) screen = null; else if (route.name === 'share' && waitingOnSavedShare) screen = null;
else if (route.name === 'share') screen = <ShareScreen id={route.id} />; else if (route.name === 'share') screen = <ShareScreen id={route.id} />;
else if (route.name === 'channel' && waitingOnSavedChannel) screen = null; else if (route.name === 'channel' && waitingOnSavedChannel) screen = null;
@@ -64,7 +75,7 @@ export function App() {
}} }}
/> />
); );
} else screen = <MissingAccess onHome={() => navigate('/')} />; } else screen = <AccountChannelAccess id={route.name === 'channel' ? route.id : ''} onHome={() => navigate('/')} />;
return ( return (
<> <>
+7 -3
View File
@@ -1,13 +1,17 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { Workspace } from '@/features/channel/Workspace'; import { Workspace } from '@/features/channel/Workspace';
import { useWorkspace } from '@/store/workspace'; import { useWorkspace } from '@/store/workspace';
import { CreateSheet } from '@/features/home/CreateSheet';
import { channelPath, navigate } from './navigation';
/** 打开(订阅)频道,数据就绪后显示暂存区 */ /** 打开(订阅)空间,数据就绪后显示暂存区 */
export function ChannelScreen({ id }: { id: string }) { export function ChannelScreen({ id }: { id: string }) {
const open = useWorkspace((s) => s.open); const open = useWorkspace((s) => s.open);
const ready = useWorkspace((s) => s.ready); const ready = useWorkspace((s) => s.ready);
const [creating, setCreating] = useState(false);
useEffect(() => open(id), [open, id]); useEffect(() => open(id), [open, id]);
return ready ? <Workspace /> : null; return ready ? <><Workspace onCreateChannel={() => setCreating(true)} />
{creating ? <CreateSheet onClose={() => setCreating(false)} onCreated={(channel) => { setCreating(false); navigate(channelPath(channel.id)); }} /> : null}</> : null;
} }
+8
View File
@@ -0,0 +1,8 @@
declare const __WEBDROP_BUILD_INFO__: {
readonly frontendVersion: string;
readonly frontendCommit: string;
readonly frontendTag: string;
readonly backendCommit: string;
readonly backendTag: string;
readonly builtAt: string;
};
+23
View File
@@ -0,0 +1,23 @@
import { callWorker } from './workerClient';
const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
export interface AccountStatus { username: string | null; revision: number; remembered?: boolean; personal?: { displayName: string; avatar?: string; modifiedAt: number }; error?: string }
export interface AccountDevice { id: string; name: string; created_at: number; last_seen: number; current: boolean }
export interface AccountInvitation { channel_id: string; username: string; payload: string; created_at: number }
export const accountApi = {
register: (username: string, password: string, remember = true) => callWorker<AccountStatus>('registerAccount', { username, password, remember, apiBase: API_BASE }),
login: (username: string, password: string, remember = true) => callWorker<AccountStatus>('loginAccount', { username, password, remember, apiBase: API_BASE }),
savedLogin: () => callWorker<{ username: string; password: string; saved: boolean }>('savedLogin', { apiBase: API_BASE }),
clearSavedLogin: () => callWorker<AccountStatus>('clearSavedLogin', { apiBase: API_BASE }),
status: () => callWorker<AccountStatus>('accountStatus', { apiBase: API_BASE }),
logout: () => callWorker<void>('logoutAccount', { apiBase: API_BASE }),
sync: () => callWorker<AccountStatus>('syncAccount', { apiBase: API_BASE }),
invitations: () => callWorker<AccountInvitation[]>('accountInvitations', { apiBase: API_BASE }),
acceptInvitation: (id: string) => callWorker<void>('acceptInvitation', { id, apiBase: API_BASE }),
updatePersonal: (displayName: string, avatar?: string) => callWorker<AccountStatus>('updatePersonal', { displayName, avatar, apiBase: API_BASE }),
devices: () => callWorker<AccountDevice[]>('accountDevices', { apiBase: API_BASE }),
renameDevice: (id: string, name: string) => callWorker<void>('renameAccountDevice', { id, name, apiBase: API_BASE }),
revokeDevice: (id: string) => callWorker<AccountStatus>('revokeAccountDevice', { id, apiBase: API_BASE }),
};
+27 -11
View File
@@ -1,27 +1,43 @@
import type { ParsedChannelLink } from '@/lib/channelLink'; import type { ParsedChannelLink } from '@/lib/channelLink';
import type { ArchiveEntry, ChannelInfo, ConnectionState, CreateOptions, WorkspaceEvent } from './types'; import type { AccountInvitation } from './account';
import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, ConnectionState, CreateOptions, FileUploadOptions, WorkspaceEvent } from './types';
/** /**
* 常驻频道的后端边界。真实实现接 @webdrop/core 与 api.md,现在由 mock.ts 提供假实现。 * 常驻空间的后端边界。真实实现接 @webdrop/core 与 api.md,现在由 mock.ts 提供假实现。
* 便捷分享有自己的边界,见 shareApi.ts。 * 便捷分享有自己的边界,见 shareApi.ts。
*/ */
export interface WorkspaceApi { export interface WorkspaceApi {
/** 新建频道:本地生成 id 与密钥、算 PoW、POST channels(api.md) */ /** 新建空间:本地生成 id 与密钥、算 PoW、POST channels(api.md) */
createChannel(opts: CreateOptions, captchaToken?: string): Promise<ChannelInfo>; createChannel(opts: CreateOptions, captchaToken?: string): Promise<ChannelInfo>;
/** 通过链接加入:先 GET 频道信息预览,再 POST seats */ /** 使用现有空间 settings 接口更新空间设置。 */
joinChannel(link: ParsedChannelLink): Promise<ChannelInfo>; updateChannelSettings(patch: ChannelSettingsPatch): Promise<void>;
renameDevice(endpointHash: string, name: string): Promise<void>;
releaseDevice(endpointHash: string): Promise<void>;
/** 通过链接加入:先 GET 空间信息预览,再 POST seats */
joinChannel(link: ParsedChannelLink, password?: string): Promise<ChannelInfo>;
inviteUser(username: string): Promise<void>;
listInvitations(): Promise<AccountInvitation[]>;
revokeInvitation(username: string): Promise<void>;
listSaved(): Promise<ChannelInfo[]>; listSaved(): Promise<ChannelInfo[]>;
/** 仅从本机删除保存的链接和终端密钥,不会删服务端频道 */ /** 仅从本机删除保存的链接和终端密钥,不会删服务端空间 */
forget(id: string): Promise<void>; forget(id: string): Promise<void>;
/** 仅假实现提供:载入演示用的频道 */ /** 仅假实现提供:载入演示用的空间 */
loadDemo?(): ChannelInfo[]; loadDemo?(): ChannelInfo[];
/** 订阅某个频道的事件流(对应追加日志的推送),返回取消订阅函数 */ /** 订阅某个空间的事件流(对应追加日志的推送),返回取消订阅函数 */
connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void; connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void;
sendText(text: string): Promise<void>; sendText(text: string, threadId?: string): Promise<void>;
sendFiles(files: File[]): Promise<void>; sendFiles(files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string): Promise<void>;
createThread(name: string, groupId?: string): Promise<string>;
createGroup(name: string): Promise<string>;
renameGroup(id: string, name: string): Promise<void>;
groupThread(id: string, groupId?: string): Promise<void>;
renameThread(threadId: string, name: string): Promise<void>;
createFolder(threadId: string, name: string, parentId?: string): Promise<void>;
renameFolder(folderId: string, threadId: string, name: string): Promise<void>;
moveItems(itemIds: string[], threadId: string, folderId?: string): Promise<void>;
/** 修改 = 追加一个替代旧条目的新版本,后写者胜 */ /** 修改 = 追加一个替代旧条目的新版本,后写者胜 */
editText(id: string, text: string): Promise<void>; editText(id: string, text: string, threadId?: string): Promise<void>;
resolveConflict(id: string, choice: 'mine' | 'theirs'): Promise<void>; resolveConflict(id: string, choice: 'mine' | 'theirs'): Promise<void>;
remove(id: string): Promise<void>; remove(id: string): Promise<void>;
setSnapshot(text: string): Promise<void>; setSnapshot(text: string): Promise<void>;
+9
View File
@@ -0,0 +1,9 @@
import { callWorker } from './workerClient';
import type { ChannelInfo } from './types';
import type { ParsedChannelLink } from '@/lib/channelLink';
const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
export interface ChannelMember { username: string; role: 'owner' | 'member'; joined_at: number }
export const channelAccessApi = {
members: (id: string) => callWorker<ChannelMember[]>('channelMembers', { id, apiBase }),
preview: (link: ParsedChannelLink) => callWorker<ChannelInfo>('previewChannel', { link, apiBase }),
};
+5 -4
View File
@@ -3,13 +3,14 @@ import { supportsApiContract } from './contract';
describe('API contract compatibility', () => { describe('API contract compatibility', () => {
it('accepts compatible SemVer releases in the supported major', () => { it('accepts compatible SemVer releases in the supported major', () => {
expect(supportsApiContract('1.0.0')).toBe(true); expect(supportsApiContract('2.0.0')).toBe(true);
expect(supportsApiContract('1.8.2')).toBe(true); expect(supportsApiContract('2.8.2')).toBe(true);
expect(supportsApiContract('1.2.0-rc.1')).toBe(true); expect(supportsApiContract('2.2.0-rc.1')).toBe(true);
}); });
it('rejects a breaking major and malformed versions', () => { it('rejects a breaking major and malformed versions', () => {
expect(supportsApiContract('2.0.0')).toBe(false); expect(supportsApiContract('1.0.0')).toBe(false);
expect(supportsApiContract('3.0.0')).toBe(false);
expect(supportsApiContract('01.0.0')).toBe(false); expect(supportsApiContract('01.0.0')).toBe(false);
expect(supportsApiContract(undefined)).toBe(false); expect(supportsApiContract(undefined)).toBe(false);
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
export const SUPPORTED_API_CONTRACT_MAJOR = 1; export const SUPPORTED_API_CONTRACT_MAJOR = 2;
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-]+)*)?$/; 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-]+)*)?$/;
+82 -9
View File
@@ -6,8 +6,16 @@ export type ManifestEntry =
| { t: 'text'; localId: string; name?: string; text: string; ts: number }; | { t: 'text'; localId: string; name?: string; text: string; ts: number };
export interface SavedResource { export interface SavedResource {
creatorAccount?: string;
localScope?: string;
modifiedAt?: number;
needsJoin?: boolean;
id: string; id: string;
kind: 'channel' | 'share'; kind: 'channel' | 'share';
/** 服务端空间设置名称的本地缓存,打开空间时会用最新设置刷新。 */
name?: string;
/** 本终端在席位列表中的自定义显示名称。 */
deviceLabel?: string;
mode: 'enc' | 'raw'; mode: 'enc' | 'raw';
/** Keys stay in IndexedDB. The page URL and localStorage never retain them. */ /** Keys stay in IndexedDB. The page URL and localStorage never retain them. */
link: string; link: string;
@@ -47,32 +55,88 @@ interface DraftFile {
} }
interface WebDropDb extends DBSchema { interface WebDropDb extends DBSchema {
resourceDeletes: { key: string; value: { id: string; modifiedAt: number } };
resources: { key: string; value: SavedResource }; resources: { key: string; value: SavedResource };
outbox: { key: string; value: QueuedFrame; indexes: { 'by-resource': string } }; outbox: { key: string; value: QueuedFrame; indexes: { 'by-resource': string; 'by-resource-created': [string, number] } };
draftFiles: { key: string; value: DraftFile; indexes: { 'by-resource': string } }; draftFiles: { key: string; value: DraftFile; indexes: { 'by-resource': string } };
} }
let dbPromise: Promise<IDBPDatabase<WebDropDb>> | undefined; const databases = new Map<string, Promise<IDBPDatabase<WebDropDb>>>();
let resourceScope = 'guest';
export function currentResourceScope(): string { return resourceScope; }
export function setResourceScope(username: string | null): void { resourceScope = username ? `account:${username}` : 'guest'; }
let resourceChangeListener: (() => void) | undefined;
export function setResourceChangeListener(listener: (() => void) | undefined): void {
resourceChangeListener = listener;
}
function db(): Promise<IDBPDatabase<WebDropDb>> { function db(): Promise<IDBPDatabase<WebDropDb>> {
dbPromise ??= openDB<WebDropDb>('webdrop-local', 2, { let dbPromise = databases.get(resourceScope);
upgrade(database, oldVersion) { dbPromise ??= openDB<WebDropDb>(`webdrop-2-${resourceScope}`, 2, {
upgrade(database, oldVersion, _newVersion, tx) {
if (oldVersion < 1) { if (oldVersion < 1) {
database.createObjectStore('resources', { keyPath: 'id' }); database.createObjectStore('resources', { keyPath: 'id' });
const outbox = database.createObjectStore('outbox', { keyPath: 'key' }); const outbox = database.createObjectStore('outbox', { keyPath: 'key' });
outbox.createIndex('by-resource', 'resourceId'); outbox.createIndex('by-resource', 'resourceId');
}
if (oldVersion < 2) {
const draftFiles = database.createObjectStore('draftFiles', { keyPath: 'key' }); const draftFiles = database.createObjectStore('draftFiles', { keyPath: 'key' });
draftFiles.createIndex('by-resource', 'resourceId'); draftFiles.createIndex('by-resource', 'resourceId');
database.createObjectStore('resourceDeletes', { keyPath: 'id' });
} }
if (oldVersion < 2) tx.objectStore('outbox').createIndex('by-resource-created', ['resourceId', 'createdAt']);
}, },
}); });
databases.set(resourceScope, dbPromise);
return dbPromise; return dbPromise;
} }
export async function saveResource(resource: SavedResource): Promise<void> { export async function saveResource(resource: SavedResource): Promise<void> {
await (await db()).put('resources', resource); if (resource.localScope && resource.localScope !== resourceScope) throw new Error('账号已切换,操作已停止');
resource.localScope = resourceScope;
resource.modifiedAt = Date.now();
const database = await db();
const tx = database.transaction(['resources', 'resourceDeletes'], 'readwrite');
await tx.objectStore('resources').put(resource);
await tx.objectStore('resourceDeletes').delete(resource.id);
await tx.done;
resourceChangeListener?.();
}
export async function listAllResources(): Promise<SavedResource[]> {
return (await db()).getAll('resources');
}
export async function listResourceDeletes(): Promise<Array<{ id: string; modifiedAt: number }>> {
return (await db()).getAll('resourceDeletes');
}
export async function importResourceDeletes(deletes: Array<{ id: string; modifiedAt: number }>): Promise<void> {
const database = await db();
const tx = database.transaction(['resources', 'resourceDeletes', 'outbox', 'draftFiles'], 'readwrite');
for (const entry of deletes) {
const resource = await tx.objectStore('resources').get(entry.id);
if (resource && (resource.modifiedAt ?? resource.createdAt) > entry.modifiedAt) continue;
await tx.objectStore('resourceDeletes').put(entry);
await tx.objectStore('resources').delete(entry.id);
for (const key of await tx.objectStore('outbox').index('by-resource').getAllKeys(entry.id)) await tx.objectStore('outbox').delete(key);
for (const key of await tx.objectStore('draftFiles').index('by-resource').getAllKeys(entry.id)) await tx.objectStore('draftFiles').delete(key);
}
await tx.done;
}
export async function importResources(resources: SavedResource[]): Promise<void> {
const database = await db();
const tx = database.transaction('resources', 'readwrite');
for (const resource of resources) {
const existing = await tx.store.get(resource.id);
await tx.store.put({ ...resource, localScope: resourceScope,
endpointSecret: existing?.endpointSecret ?? crypto.getRandomValues(new Uint8Array(32)),
deviceLabel: existing?.deviceLabel, localItems: existing?.localItems,
pendingManifestId: existing?.pendingManifestId,
needsJoin: existing?.needsJoin ?? (!existing && resource.kind === 'channel'),
});
}
await tx.done;
} }
export async function getResource(id: string): Promise<SavedResource | undefined> { export async function getResource(id: string): Promise<SavedResource | undefined> {
@@ -86,13 +150,15 @@ export async function listResources(kind: SavedResource['kind']): Promise<SavedR
export async function removeResource(id: string): Promise<void> { export async function removeResource(id: string): Promise<void> {
const database = await db(); const database = await db();
const tx = database.transaction(['resources', 'outbox', 'draftFiles'], 'readwrite'); const tx = database.transaction(['resources', 'outbox', 'draftFiles', 'resourceDeletes'], 'readwrite');
await tx.objectStore('resources').delete(id); await tx.objectStore('resources').delete(id);
await tx.objectStore('resourceDeletes').put({ id, modifiedAt: Date.now() });
const keys = await tx.objectStore('outbox').index('by-resource').getAllKeys(id); const keys = await tx.objectStore('outbox').index('by-resource').getAllKeys(id);
await Promise.all(keys.map((key) => tx.objectStore('outbox').delete(key))); await Promise.all(keys.map((key) => tx.objectStore('outbox').delete(key)));
const fileKeys = await tx.objectStore('draftFiles').index('by-resource').getAllKeys(id); const fileKeys = await tx.objectStore('draftFiles').index('by-resource').getAllKeys(id);
await Promise.all(fileKeys.map((key) => tx.objectStore('draftFiles').delete(key))); await Promise.all(fileKeys.map((key) => tx.objectStore('draftFiles').delete(key)));
await tx.done; await tx.done;
resourceChangeListener?.();
} }
export async function saveDraftFile(resourceId: string, itemId: string, file: File): Promise<void> { export async function saveDraftFile(resourceId: string, itemId: string, file: File): Promise<void> {
@@ -130,10 +196,17 @@ export async function dequeueFrame(key: string): Promise<void> {
} }
export async function nextQueuedFrame(resourceId: string): Promise<QueuedFrame | undefined> { export async function nextQueuedFrame(resourceId: string): Promise<QueuedFrame | undefined> {
const values = await (await db()).getAllFromIndex('outbox', 'by-resource', resourceId, 1); const range = IDBKeyRange.bound([resourceId, 0], [resourceId, Number.MAX_SAFE_INTEGER]);
const values = await (await db()).getAllFromIndex('outbox', 'by-resource-created', range, 1);
return values[0]; return values[0];
} }
export async function lastQueuedAt(resourceId: string): Promise<number> {
const range = IDBKeyRange.bound([resourceId, 0], [resourceId, Number.MAX_SAFE_INTEGER]);
const cursor = await (await db()).transaction('outbox').store.index('by-resource-created').openCursor(range, 'prev');
return cursor?.value.createdAt ?? 0;
}
export async function dequeueItem(resourceId: string, itemId: string): Promise<void> { export async function dequeueItem(resourceId: string, itemId: string): Promise<void> {
const database = await db(); const database = await db();
const tx = database.transaction('outbox', 'readwrite'); const tx = database.transaction('outbox', 'readwrite');
+6 -2
View File
@@ -1,13 +1,17 @@
const messages: Record<string, string> = { const messages: Record<string, string> = {
not_found: '内容不存在或已过期。', gone: '内容已删除、下载次数已用完或续传已失效。', not_found: '内容不存在或已过期。', gone: '内容已删除、下载次数已用完或续传已失效。',
seats_full: '频道席位已满,请联系所有者。', not_joined: '你已被移出此频道。', seats_full: '空间席位已满,请联系所有者。', not_joined: '你已被移出此空间。',
visitors_full: '分享的访客名额已满。', unauthorized: '链接权限无效,请使用完整链接重新打开。', visitors_full: '分享的访客名额已满。', unauthorized: '链接权限无效,请使用完整链接重新打开。',
forbidden: '此链接没有执行此操作的权限。', sealed: '分享已封存,无法再修改。', forbidden: '此链接没有执行此操作的权限。', sealed: '分享已封存,无法再修改。',
too_large: '内容超过服务端容量或条目数量限制。', rate_limited: '请求过于频繁,请稍后重试。', too_large: '内容超过服务端容量或条目数量限制。', rate_limited: '请求过于频繁,请稍后重试。',
captcha_required: '验证码未通过或已失效,请重新创建。', captcha_required: '验证码未通过或已失效,请重新创建。',
captcha_unavailable: '验证码服务暂时不可用,请稍后重试。', captcha_unavailable: '验证码服务暂时不可用,请稍后重试。',
api_contract_mismatch: '服务端 API 有破坏性更新,请刷新或更新前端后再试。', api_contract_mismatch: '服务端 API 有破坏性更新,请刷新或更新前端后再试。',
mode_mismatch: '频道加密模式与链接不符,已拒绝访问。', conflict: '上传内容发生冲突,已停止发送。', mode_mismatch: '空间加密模式与链接不符,已拒绝访问。', conflict: '上传内容发生冲突,已停止发送。',
username_unavailable: '这个用户名已被使用。', vault_conflict: '账号资源在其他设备上刚刚更新,请再次同步。',
account_invalid: '用户名或密码错误,或登录已过期。', account_required: '请先登录账号。',
invitation_required: '该空间仅允许受邀账号加入,请登录受邀账号或联系空间主。',
channel_password_required: '请输入空间加入密码。', channel_password_invalid: '空间加入密码不正确。',
}; };
export function errorMessage(error: unknown): string { export function errorMessage(error: unknown): string {
+74 -10
View File
@@ -3,7 +3,7 @@ import { previewKindOf } from '@/lib/fileKind';
import type { WorkspaceApi } from './api'; import type { WorkspaceApi } from './api';
import { b64url } from './mockLinks'; import { b64url } from './mockLinks';
import { PHONE, SELF, archiveEntries, channel as demoChannel, devices as demoDevices, makeSvgBlob, makeVideoBlob, seedItems, snapshot as demoSnapshot } from './mockSeed'; import { PHONE, SELF, archiveEntries, channel as demoChannel, devices as demoDevices, makeSvgBlob, makeVideoBlob, seedItems, snapshot as demoSnapshot } from './mockSeed';
import type { ChannelInfo, ConnectionState, CreateOptions, Device, FileItem, Item, Snapshot, WorkspaceEvent } from './types'; import type { ChannelInfo, ConnectionState, CreateOptions, Device, FileItem, FileUploadOptions, Item, Snapshot, WorkspaceEvent } from './types';
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)); const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
@@ -28,9 +28,9 @@ interface Runtime {
demo: boolean; demo: boolean;
} }
const selfDevice: Device = { id: SELF, name: '本机', kind: 'desktop', online: true, self: true }; const selfDevice: Device = { id: SELF, name: '本机', kind: 'desktop', self: true, joinedAt: Date.now() };
/** 频道的假实现:不联网,数据都在内存里,刷新即丢。接口见 api.ts */ /** 空间的假实现:不联网,数据都在内存里,刷新即丢。接口见 api.ts */
export function createMockApi(): WorkspaceApi { export function createMockApi(): WorkspaceApi {
const runtimes = new Map<string, Runtime>(); const runtimes = new Map<string, Runtime>();
const timers = new Set<number>(); const timers = new Set<number>();
@@ -68,7 +68,7 @@ export function createMockApi(): WorkspaceApi {
return rt; return rt;
} }
/** 演示频道里的实时动静:iPhone 发来消息、改了文件、短暂编辑便签 */ /** 演示空间里的实时动静:iPhone 发来消息、改了文件、短暂编辑便签 */
function runDemoActivity(rt: Runtime) { function runDemoActivity(rt: Runtime) {
later(9000, () => put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text: '到楼下了' })); later(9000, () => put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text: '到楼下了' }));
later(14000, () => { later(14000, () => {
@@ -82,13 +82,16 @@ export function createMockApi(): WorkspaceApi {
} }
return { return {
async inviteUser() {},
async listInvitations() { return []; },
async revokeInvitation() {},
async listSaved() { return []; }, async listSaved() { return []; },
async forget() {}, async forget() {},
async createChannel(opts) { async createChannel(opts) {
await wait(300); await wait(300);
const id = b64url(16); const id = b64url(16);
const createdAt = Date.now(); 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) }; const info: ChannelInfo = { id, name: opts.name?.trim() || undefined, mode: opts.mode, role: 'owner', seats: opts.seats, seatsUsed: 1, membersCanUpload: opts.membersCanUpload, maxDownloads: opts.fileMaxDownloads ?? 10, ttlSeconds: opts.ttlSeconds, createdAt, expiresAt: createdAt + opts.ttlSeconds * 1000, ...buildLinks(id, opts) };
newRuntime(info, [selfDevice]); newRuntime(info, [selfDevice]);
return info; return info;
}, },
@@ -106,12 +109,16 @@ export function createMockApi(): WorkspaceApi {
mode: link.mode, mode: link.mode,
role: link.isOwner ? 'owner' : 'member', role: link.isOwner ? 'owner' : 'member',
seats: 3, seats: 3,
seatsUsed: 1,
membersCanUpload: true,
maxDownloads: 10,
ttlSeconds: 86400,
createdAt: Date.now(), createdAt: Date.now(),
expiresAt: Date.now() + 86400_000, expiresAt: Date.now() + 86400_000,
memberLink: member ? `${base}#${member}` : base, memberLink: member ? `${base}#${member}` : base,
ownerLink: link.isOwner ? `${base}#${frag(link.keys)}` : undefined, ownerLink: link.isOwner ? `${base}#${frag(link.keys)}` : undefined,
}; };
newRuntime(info, [selfDevice, { id: 'peer', name: '创建者的设备', kind: 'desktop', online: true }]); newRuntime(info, [selfDevice, { id: 'peer', name: '创建者的设备', kind: 'desktop', joinedAt: Date.now() }]);
return info; return info;
}, },
@@ -140,16 +147,48 @@ export function createMockApi(): WorkspaceApi {
}; };
}, },
async sendText(text) { async sendText(text, threadId) {
put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: SELF, state: { kind: 'complete' }, text }); put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: SELF, state: { kind: 'complete' }, text, ...(threadId ? { threadId } : {}) });
}, },
async sendFiles(files) { async updateChannelSettings(patch) {
if (!current || current.info.role !== 'owner') throw new Error('只有所有者可以修改空间设置');
current.info = {
...current.info,
...(patch.name === undefined ? {} : { name: patch.name.trim() || undefined }),
...(patch.seats === undefined ? {} : { seats: patch.seats }),
...(patch.membersCanUpload === undefined ? {} : { membersCanUpload: patch.membersCanUpload, canUpload: current.info.role === 'owner' || patch.membersCanUpload }),
...(patch.ttlSeconds === undefined ? {} : { expiresAt: Date.now() + patch.ttlSeconds * 1000 }),
...(patch.fileMaxDownloads === undefined ? {} : { maxDownloads: patch.fileMaxDownloads }),
};
emit({ t: 'channel', info: current.info });
},
async renameDevice(endpointHash, name) {
if (!current) throw new Error('空间未打开');
const device = current.devices.find((entry) => entry.id === endpointHash);
if (!device) throw new Error('设备不存在');
if (current.info.role !== 'owner' && !device.self) throw new Error('只有所有者可以重命名其他设备');
current.devices = current.devices.map((entry) => entry.id === endpointHash ? { ...entry, name: name.trim() } : entry);
emit({ t: 'devices', devices: current.devices });
},
async releaseDevice(endpointHash) {
if (!current || current.info.role !== 'owner') throw new Error('只有所有者可以移除设备');
current.devices = current.devices.filter((entry) => entry.id !== endpointHash);
current.info = { ...current.info, seatsUsed: current.devices.length };
emit({ t: 'devices', devices: current.devices });
emit({ t: 'channel', info: current.info });
},
async sendFiles(files, options: FileUploadOptions = { directPreview: true, maxDownloads: 10 }, threadId, folderId) {
for (const file of files) { for (const file of files) {
const id = uid(); const id = uid();
const base: FileItem = { const base: FileItem = {
id, kind: 'file', ts: Date.now(), deviceId: SELF, state: { kind: 'receiving', progress: 0 }, id, kind: 'file', ts: Date.now(), deviceId: SELF, state: { kind: 'receiving', progress: 0 },
name: file.name, mime: file.type, size: file.size, remaining: 10, ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}),
name: file.name, mime: file.type, size: file.size, remaining: options.directPreview || options.maxDownloads === 0 ? null : options.maxDownloads,
gated: !options.directPreview,
preview: previewKindOf(file.type, file.name), preview: previewKindOf(file.type, file.name),
}; };
current?.blobs.set(id, file); current?.blobs.set(id, file);
@@ -163,6 +202,31 @@ export function createMockApi(): WorkspaceApi {
} }
}, },
async createThread(name, groupId) {
const id = crypto.randomUUID();
emit({ t: 'thread-created', thread: { id, name: name.trim(), createdAt: Date.now(), createdBy: SELF, ...(groupId ? { groupId } : {}) } });
return id;
},
async renameThread(threadId, name) { emit({ t: 'thread-renamed', id: threadId, name: name.trim() }); },
async createGroup(name) { const id = crypto.randomUUID(); emit({ t: 'group-created', group: { id, name: name.trim(), createdAt: Date.now() } }); return id; },
async renameGroup(id, name) { emit({ t: 'group-renamed', id, name: name.trim() }); },
async groupThread(id, groupId) { emit({ t: 'thread-grouped', id, ...(groupId ? { groupId } : {}) }); },
async createFolder(threadId, name, parentId) {
emit({ t: 'folder-created', folder: { id: crypto.randomUUID(), threadId, ...(parentId ? { parentId } : {}), name: name.trim() } });
},
async renameFolder(folderId, _threadId, name) { emit({ t: 'folder-renamed', id: folderId, name: name.trim() }); },
async moveItems(itemIds, threadId, folderId) {
for (const id of itemIds) {
const item = current?.items.get(id);
if (item) put({ ...item, threadId, folderId });
}
emit({ t: 'items-moved', itemIds, threadId, ...(folderId ? { folderId } : {}) });
},
async editText(id, text) { async editText(id, text) {
const cur = current?.items.get(id); const cur = current?.items.get(id);
if (cur?.kind !== 'text') return; if (cur?.kind !== 'text') return;
+4 -3
View File
@@ -9,9 +9,9 @@ export const DEMO_ID = 'k3Fq9xWm2LpA7sTdQ8vZ1n';
const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF'; const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
export const devices: Device[] = [ export const devices: Device[] = [
{ id: SELF, name: '本机 · Chrome', kind: 'desktop', online: true, self: true }, { id: SELF, name: '本机 · Chrome', kind: 'desktop', self: true, joinedAt: Date.now() },
{ id: PHONE, name: 'iPhone', kind: 'phone', online: true }, { id: PHONE, name: 'iPhone', kind: 'phone', joinedAt: Date.now() - 3600_000 },
{ id: 'tablet', name: 'iPad', kind: 'tablet', online: false, lastSeen: Date.now() - 3 * 3600_000 }, { id: 'tablet', name: 'iPad', kind: 'tablet', joinedAt: Date.now() - 3 * 3600_000 },
]; ];
export const channel: ChannelInfo = { export const channel: ChannelInfo = {
@@ -19,6 +19,7 @@ export const channel: ChannelInfo = {
mode: 'enc', mode: 'enc',
role: 'owner', role: 'owner',
seats: 3, seats: 3,
seatsUsed: 3,
createdAt: Date.now() - 3 * 3600_000, createdAt: Date.now() - 3 * 3600_000,
expiresAt: Date.now() + 21 * 3600_000, expiresAt: Date.now() + 21 * 3600_000,
memberLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}`, memberLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}`,
+20 -5
View File
@@ -1,9 +1,10 @@
import type { ParsedChannelLink } from '@/lib/channelLink'; import type { ParsedChannelLink } from '@/lib/channelLink';
import type { AccountInvitation } from './account';
import type { ParsedShareLink } from '@/lib/shareLink'; import type { ParsedShareLink } from '@/lib/shareLink';
import type { WorkspaceApi } from './api'; import type { WorkspaceApi } from './api';
import type { ShareApi } from './shareApi'; import type { ShareApi } from './shareApi';
import { callWorker, subscribeWorker } from './workerClient'; import { callWorker, subscribeWorker } from './workerClient';
import type { ArchiveEntry, ChannelInfo, ConnectionState, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types'; import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, ConnectionState, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types';
const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
@@ -15,7 +16,13 @@ function reportChannelUnavailable(id: string, onEvent: (event: WorkspaceEvent) =
export const realApi: WorkspaceApi = { export const realApi: WorkspaceApi = {
createChannel: (options: CreateOptions, captchaToken?: string) => callWorker<ChannelInfo>('createChannel', { options, captchaToken, apiBase: API_BASE }), createChannel: (options: CreateOptions, captchaToken?: string) => callWorker<ChannelInfo>('createChannel', { options, captchaToken, apiBase: API_BASE }),
joinChannel: (link: ParsedChannelLink) => callWorker<ChannelInfo>('joinChannel', { link, apiBase: API_BASE }), updateChannelSettings: (patch: ChannelSettingsPatch) => callWorker<void>('updateChannelSettings', { id: currentChannelId(), patch, apiBase: API_BASE }),
renameDevice: (endpointHash, name) => callWorker<void>('renameDevice', { id: currentChannelId(), endpointHash, name, apiBase: API_BASE }),
releaseDevice: (endpointHash) => callWorker<void>('releaseDevice', { id: currentChannelId(), endpointHash, apiBase: API_BASE }),
joinChannel: (link: ParsedChannelLink, password?: string) => callWorker<ChannelInfo>('joinChannel', { link, password, apiBase: API_BASE }),
inviteUser: (username) => callWorker<void>('inviteChannelUser', { id: currentChannelId(), username, apiBase: API_BASE }),
listInvitations: () => callWorker<AccountInvitation[]>('listChannelInvitations', { id: currentChannelId(), apiBase: API_BASE }),
revokeInvitation: (username) => callWorker<void>('revokeChannelInvitation', { id: currentChannelId(), username, apiBase: API_BASE }),
listSaved: () => callWorker<ChannelInfo[]>('listChannels', { apiBase: API_BASE }), listSaved: () => callWorker<ChannelInfo[]>('listChannels', { apiBase: API_BASE }),
forget: (id) => callWorker<void>('forgetChannel', { id, apiBase: API_BASE }), forget: (id) => callWorker<void>('forgetChannel', { id, apiBase: API_BASE }),
@@ -30,9 +37,17 @@ export const realApi: WorkspaceApi = {
}; };
}, },
sendText: (text) => callWorker<void>('sendText', { id: currentChannelId(), text, apiBase: API_BASE }), sendText: (text, threadId) => callWorker<void>('sendText', { id: currentChannelId(), text, threadId, apiBase: API_BASE }),
sendFiles: (files) => callWorker<void>('sendFiles', { id: currentChannelId(), files, apiBase: API_BASE }), sendFiles: (files, options, threadId, folderId) => callWorker<void>('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, apiBase: API_BASE }),
editText: (id, text) => callWorker<void>('editChannelText', { id: currentChannelId(), itemId: id, text, apiBase: API_BASE }), createThread: (name, groupId) => callWorker<string>('createThread', { id: currentChannelId(), name, groupId, apiBase: API_BASE }),
createGroup: (name) => callWorker<string>('createGroup', { id: currentChannelId(), name, apiBase: API_BASE }),
renameGroup: (groupId, name) => callWorker<void>('renameGroup', { id: currentChannelId(), groupId, name, apiBase: API_BASE }),
groupThread: (threadId, groupId) => callWorker<void>('groupThread', { id: currentChannelId(), threadId, groupId, apiBase: API_BASE }),
renameThread: (threadId, name) => callWorker<void>('renameThread', { id: currentChannelId(), threadId, name, apiBase: API_BASE }),
createFolder: (threadId, name, parentId) => callWorker<void>('createFolder', { id: currentChannelId(), threadId, name, parentId, apiBase: API_BASE }),
renameFolder: (folderId, threadId, name) => callWorker<void>('renameFolder', { id: currentChannelId(), folderId, threadId, name, apiBase: API_BASE }),
moveItems: (itemIds, threadId, folderId) => callWorker<void>('moveItems', { id: currentChannelId(), itemIds, threadId, folderId, apiBase: API_BASE }),
editText: (id, text, threadId) => callWorker<void>('editChannelText', { id: currentChannelId(), itemId: id, text, threadId, apiBase: API_BASE }),
resolveConflict: (id, choice) => callWorker<void>('resolveTextConflict', { id: currentChannelId(), itemId: id, choice, 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 }), remove: (id) => callWorker<void>('removeChannelItem', { id: currentChannelId(), itemId: id, apiBase: API_BASE }),
setSnapshot: (text) => callWorker<void>('updateSnapshot', { id: currentChannelId(), text, apiBase: API_BASE }), setSnapshot: (text) => callWorker<void>('updateSnapshot', { id: currentChannelId(), text, apiBase: API_BASE }),
+1 -1
View File
@@ -2,7 +2,7 @@ import type { ParsedShareLink } from '@/lib/shareLink';
import type { ArchiveEntry, Share, ShareEvent, ShareSettings } from './types'; import type { ArchiveEntry, Share, ShareEvent, ShareSettings } from './types';
/** /**
* 便捷分享的后端边界。和频道分开:分享没有设备、便签、冲突与历史,只有 * 便捷分享的后端边界。和空间分开:分享没有设备、便签、冲突与历史,只有
* 「草稿 → 封存 → 过期」。线路上仍是 channels(成员不可上传),所以由同一个 core 会话实现。 * 「草稿 → 封存 → 过期」。线路上仍是 channels(成员不可上传),所以由同一个 core 会话实现。
*/ */
export interface ShareApi { export interface ShareApi {
+59 -6
View File
@@ -6,19 +6,29 @@ export interface Device {
id: string; id: string;
name: string; name: string;
kind: DeviceKind; kind: DeviceKind;
online: boolean;
self?: boolean; self?: boolean;
/** 正在编辑便签 */ /** 正在编辑便签 */
editing?: boolean; editing?: boolean;
lastSeen?: number; joinedAt?: number;
} }
/** 常驻频道:自己多台设备之间长期同步 */ /** 常驻空间:自己多台设备之间长期同步 */
export interface ChannelInfo { export interface ChannelInfo {
creatorAccount?: string;
joinPolicy?: 'open' | 'invite';
allowGuests?: boolean;
passwordRequired?: boolean;
id: string; id: string;
/** 服务端 channel settings 中的共享名称,加入者可见。 */
name?: string;
mode: 'enc' | 'raw'; mode: 'enc' | 'raw';
role: 'owner' | 'member'; role: 'owner' | 'member';
seats: number; seats: number;
seatsUsed?: number;
membersCanUpload?: boolean;
ttlSeconds?: number;
/** 每个新上传文件的默认下载次数上限。 */
maxDownloads?: number;
memberLink: string; memberLink: string;
/** 只有所有者有 */ /** 只有所有者有 */
ownerLink?: string; ownerLink?: string;
@@ -27,12 +37,34 @@ export interface ChannelInfo {
canUpload?: boolean; canUpload?: boolean;
} }
/** 新建频道的选项(protocol §12 设置的子集) */ /** 新建空间的选项(protocol §12 设置的子集) */
export interface CreateOptions { export interface CreateOptions {
joinPolicy?: 'open' | 'invite';
allowGuests?: boolean;
joinPassword?: string;
name?: string;
mode: 'enc' | 'raw'; mode: 'enc' | 'raw';
seats: number; seats: number;
ttlSeconds: number; ttlSeconds: number;
membersCanUpload: boolean; membersCanUpload: boolean;
fileMaxDownloads?: number;
}
export interface FileUploadOptions {
/** 单帧文件可随空间同步,加入者能立即预览;多帧文件仍由空间上限保护。 */
directPreview: boolean;
maxDownloads: number;
}
export interface ChannelSettingsPatch {
joinPolicy?: 'open' | 'invite';
allowGuests?: boolean;
joinPassword?: string;
name?: string;
seats?: number;
membersCanUpload?: boolean;
ttlSeconds?: number;
fileMaxDownloads?: number;
} }
/** /**
@@ -65,18 +97,23 @@ export interface ShareSettings {
/** protocol §7、§14:由帧推导,不单独存储 */ /** protocol §7、§14:由帧推导,不单独存储 */
export type ItemState = export type ItemState =
| { kind: 'receiving'; progress: number } | { kind: 'receiving'; progress: number; phase?: 'encrypting' | 'uploading'; speedBytesPerSecond?: number }
| { kind: 'complete' } | { kind: 'complete' }
| { kind: 'incomplete' } | { kind: 'incomplete' }
| { kind: 'tampered' }; | { kind: 'tampered' };
interface ItemBase { interface ItemBase {
author?: SenderInfo;
id: string; id: string;
ts: number; ts: number;
deviceId: string; deviceId: string;
state: ItemState; state: ItemState;
threadId?: string;
folderId?: string;
} }
export interface SenderInfo { username: string; displayName: string; avatar?: string }
export interface TextItem extends ItemBase { export interface TextItem extends ItemBase {
kind: 'text'; kind: 'text';
text: string; text: string;
@@ -94,6 +131,8 @@ export interface FileItem extends ItemBase {
remaining: number | null; remaining: number | null;
preview: PreviewKind; preview: PreviewKind;
gated?: boolean; gated?: boolean;
/** announce-only placeholder; omit thread/folder/name until explicit item read. */
metadataHidden?: boolean;
/** /**
* 旧版本,新的在前,不含当前版本。 * 旧版本,新的在前,不含当前版本。
* 约定(待写入 protocol §7):新版本是一个新条目,其加密的 meta_json 带 `replaces`, * 约定(待写入 protocol §7):新版本是一个新条目,其加密的 meta_json 带 `replaces`,
@@ -119,13 +158,17 @@ export interface ArchiveEntry {
dir: boolean; dir: boolean;
} }
/** protocol §9:频道唯一的快照槽,最后写入者胜 */ /** protocol §9:空间唯一的快照槽,最后写入者胜 */
export interface Snapshot { export interface Snapshot {
text: string; text: string;
updatedBy: string; updatedBy: string;
updatedAt: number; updatedAt: number;
} }
export interface ThreadInfo { id: string; name: string; createdAt: number; createdBy: string; groupId?: string }
export interface ChannelGroupInfo { id: string; name: string; createdAt: number }
export interface FolderInfo { id: string; threadId: string; parentId?: string; name: string }
export type WorkspaceEvent = export type WorkspaceEvent =
| { t: 'reset' } | { t: 'reset' }
| { t: 'error'; message: string } | { t: 'error'; message: string }
@@ -134,7 +177,17 @@ export type WorkspaceEvent =
| { t: 'channel'; info: ChannelInfo } | { t: 'channel'; info: ChannelInfo }
| { t: 'devices'; devices: Device[] } | { t: 'devices'; devices: Device[] }
| { t: 'item'; item: Item } | { t: 'item'; item: Item }
| { t: 'file-catalog'; item: FileItem }
| { t: 'item-author'; id: string; author: SenderInfo }
| { t: 'removed'; id: string } | { t: 'removed'; id: string }
| { t: 'thread-created'; thread: ThreadInfo }
| { t: 'thread-renamed'; id: string; name: string }
| { t: 'group-created'; group: ChannelGroupInfo }
| { t: 'group-renamed'; id: string; name: string }
| { t: 'thread-grouped'; id: string; groupId?: string }
| { t: 'folder-created'; folder: FolderInfo }
| { t: 'folder-renamed'; id: string; name: string }
| { t: 'items-moved'; itemIds: string[]; threadId: string; folderId?: string }
| { t: 'snapshot'; snapshot: Snapshot }; | { t: 'snapshot'; snapshot: Snapshot };
export type ShareEvent = export type ShareEvent =
+38 -9
View File
@@ -1,6 +1,6 @@
import { setApiBase } from './worker/http'; import { setApiBase } from './worker/http';
import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems'; import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems';
import { createChannel, forgetChannel, joinChannel, listChannels } from './worker/channelResources'; import { createChannel, forgetChannel, joinChannel, listChannels, releaseDevice, renameDevice, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend'; import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend';
import { stopChannel } from './worker/channelTransport'; import { stopChannel } from './worker/channelTransport';
import { addShareFiles, addShareText, editShareText, removeShareItem } from './worker/shareDraft'; import { addShareFiles, addShareText, editShareText, removeShareItem } from './worker/shareDraft';
@@ -10,6 +10,9 @@ import { sealShare } from './worker/shareSeal';
import { setEventSink } from './worker/port'; import { setEventSink } from './worker/port';
import { serial } from './worker/serial'; import { serial } from './worker/serial';
import { errorMessage } from './errors'; import { errorMessage } from './errors';
import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account';
import { createFolder, createThread, createGroup, renameGroup, groupThread, moveItems, renameFolder, renameThread } from './worker/channelThreads';
import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account';
interface WorkerRequest { requestId: number; operation: string; payload: unknown } interface WorkerRequest { requestId: number; operation: string; payload: unknown }
interface WorkerScope { interface WorkerScope {
@@ -23,20 +26,50 @@ setEventSink((resourceId, event) => scope.postMessage({ resourceId, event }));
async function dispatch(operation: string, input: unknown): Promise<unknown> { async function dispatch(operation: string, input: unknown): Promise<unknown> {
const payload = input as Record<string, unknown>; const payload = input as Record<string, unknown>;
if (typeof payload.apiBase === 'string') setApiBase(payload.apiBase); if (typeof payload.apiBase === 'string') setApiBase(payload.apiBase);
await restoreAccount();
const id = typeof payload.id === 'string' ? payload.id : ''; const id = typeof payload.id === 'string' ? payload.id : '';
switch (operation) { switch (operation) {
case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), typeof payload.avatar === 'string' ? payload.avatar : undefined); return accountStatus();
case 'accountDevices': return listAccountDevices();
case 'renameAccountDevice': await renameAccountDevice(id, String(payload.name ?? '')); return true;
case 'revokeAccountDevice': await revokeAccountDevice(id); return accountStatus();
case 'registerAccount': return registerAccount(String(payload.username ?? ''), String(payload.password ?? ''), payload.remember !== false);
case 'loginAccount': return loginAccount(String(payload.username ?? ''), String(payload.password ?? ''), payload.remember !== false);
case 'savedLogin': return getSavedLogin();
case 'clearSavedLogin': await clearSavedLogin(); return accountStatus();
case 'accountStatus': return accountStatus();
case 'logoutAccount': await logoutAccount(); return true;
case 'syncAccount': await syncAccount(); return accountStatus();
case 'accountInvitations': return listAccountInvitations();
case 'acceptInvitation': return acceptInvitation(id);
case 'inviteChannelUser': return inviteChannelUser(id, String(payload.username ?? ''));
case 'listChannelInvitations': return listChannelInvitations(id);
case 'revokeChannelInvitation': return revokeChannelInvitation(id, String(payload.username ?? ''));
case 'createChannel': return createChannel(payload.options as Parameters<typeof createChannel>[0], typeof payload.captchaToken === 'string' ? payload.captchaToken : undefined); case 'createChannel': return createChannel(payload.options as Parameters<typeof createChannel>[0], typeof payload.captchaToken === 'string' ? payload.captchaToken : undefined);
case 'updateChannelSettings': return updateChannelSettings(id, payload.patch as Parameters<typeof updateChannelSettings>[1]);
case 'createThread': return createThread(id, String(payload.name ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined);
case 'createGroup': return createGroup(id, String(payload.name ?? ''));
case 'renameGroup': return renameGroup(id, String(payload.groupId ?? ''), String(payload.name ?? ''));
case 'groupThread': return groupThread(id, String(payload.threadId ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined);
case 'renameThread': return renameThread(id, String(payload.threadId ?? ''), String(payload.name ?? ''));
case 'createFolder': return createFolder(id, String(payload.threadId ?? ''), String(payload.name ?? ''), typeof payload.parentId === 'string' ? payload.parentId : undefined);
case 'renameFolder': return renameFolder(id, String(payload.folderId ?? ''), String(payload.name ?? ''), String(payload.threadId ?? ''));
case 'moveItems': return moveItems(id, payload.itemIds as string[], String(payload.threadId ?? ''), typeof payload.folderId === 'string' ? payload.folderId : undefined);
case 'renameDevice': return renameDevice(id, String(payload.endpointHash ?? ''), String(payload.name ?? ''));
case 'releaseDevice': return releaseDevice(id, String(payload.endpointHash ?? ''));
case 'joinChannel': return joinChannel(payload as Parameters<typeof joinChannel>[0]); case 'joinChannel': return joinChannel(payload as Parameters<typeof joinChannel>[0]);
case 'previewChannel': return previewChannel(payload.link as Parameters<typeof previewChannel>[0]);
case 'channelMembers': return channelMembers(id);
case 'listChannels': return listChannels(); case 'listChannels': return listChannels();
case 'forgetChannel': return forgetChannel(id); case 'forgetChannel': return forgetChannel(id);
case 'connectChannel': void connectChannelResource(id); return true; case 'connectChannel': void connectChannelResource(id); return true;
case 'stopChannel': stopChannel(id); return true; case 'stopChannel': stopChannel(id); return true;
case 'sendText': return sendText(id, String(payload.text ?? '')); case 'sendText': return sendText(id, String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined);
case 'sendFiles': return sendFiles(id, payload.files as File[]); case 'sendFiles': return sendFiles(id, payload.files as File[], payload.options as Parameters<typeof sendFiles>[2] | undefined, typeof payload.threadId === 'string' ? payload.threadId : undefined, typeof payload.folderId === 'string' ? payload.folderId : undefined);
case 'removeChannelItem': return removeChannelItem(id, String(payload.itemId)); case 'removeChannelItem': return removeChannelItem(id, String(payload.itemId));
case 'getChannelBlob': return getChannelBlob(id, String(payload.itemId), typeof payload.versionId === 'string' ? payload.versionId : undefined); case 'getChannelBlob': return getChannelBlob(id, String(payload.itemId), typeof payload.versionId === 'string' ? payload.versionId : undefined);
case 'updateSnapshot': return updateSnapshot(id, String(payload.text ?? '')); case 'updateSnapshot': return updateSnapshot(id, String(payload.text ?? ''));
case 'editChannelText': return editChannelText(id, String(payload.itemId), String(payload.text ?? '')); case 'editChannelText': return editChannelText(id, String(payload.itemId), String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined);
case 'resolveTextConflict': return resolveTextConflict(); case 'resolveTextConflict': return resolveTextConflict();
case 'markChannelSeen': return markChannelSeen(); case 'markChannelSeen': return markChannelSeen();
case 'listArchive': return listArchive(id); case 'listArchive': return listArchive(id);
@@ -59,11 +92,7 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
scope.onmessage = (event) => { scope.onmessage = (event) => {
const request = event.data; const request = event.data;
const payload = request.payload as { id?: string; link?: { id: string } }; const task = serial('account-scope', () => dispatch(request.operation, request.payload));
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( void task.then(
(result) => scope.postMessage({ requestId: request.requestId, result }), (result) => scope.postMessage({ requestId: request.requestId, result }),
(error: unknown) => scope.postMessage({ (error: unknown) => scope.postMessage({
+233
View File
@@ -0,0 +1,233 @@
import { importResources, importResourceDeletes, listAllResources, listResourceDeletes, setResourceChangeListener, setResourceScope, type SavedResource } from '../database';
import { apiFetch } from './http';
import { clearResourceSessions, getSession } from './sessions';
import { setAccountIdentity, requireAccount } from './accountSession';
import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys } from './accountCrypto';
import { mergeVault, validateVault, validatePersonal, type VaultProfile, type PersonalProfile } from './accountVault';
import { joinChannel } from './channelResources';
import { openChannelGrant, sealChannelGrant } from './channelGrant';
import { recoverAccountChannels } from './accountChannels';
import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, saveDeviceName, saveLoginFields, forgetLoginFields, savedLoginFields } from './accountPersistence';
interface VaultBody { revision: number; vault: string }
interface AccountSession {
username: string; token: string; key: CryptoKey; profile: VaultProfile; revision: number;
remember: boolean;
controller: AbortController; dirty: boolean; syncing?: Promise<void>; error?: string;
timer?: ReturnType<typeof setTimeout>; polling?: ReturnType<typeof setInterval>;
}
export interface Invitation { channel_id: string; username: string; payload: string; created_at: number }
let account: AccountSession | undefined;
let restoration: Promise<void> | undefined;
function usernameOf(input: string): string {
const username = input.trim().toLowerCase();
if (!/^[a-z0-9_-]{3,32}$/.test(username)) throw new Error('用户名需为 3–32 位字母、数字、下划线或短横线');
return username;
}
function headers(session: Pick<AccountSession, 'token'>): Record<string, string> { return { 'X-WD-Account': session.token }; }
function stillActive(session: AccountSession): void {
if (account !== session || session.controller.signal.aborted) throw new Error('账号已切换,操作已停止');
}
async function applyProfile(profile: VaultProfile): Promise<void> {
await importResources(profile.resources.map((row) => ({ ...row, endpointSecret: new Uint8Array(32) } as SavedResource)));
await importResourceDeletes(profile.deletes);
}
async function persistSession(session: AccountSession, value: VaultBody): Promise<void> {
if (session.remember) await rememberAccount({ username: session.username, token: session.token, key: session.key, ...value });
else await forgetAccount();
}
async function syncVault(): Promise<void> {
if (!account) throw new Error('请先登录账号');
const session = account;
if (session.syncing) { session.dirty = true; return session.syncing; }
session.syncing = (async () => {
await recoverAccountChannels(session.username, session.profile.keys, session.controller.signal);
do {
session.dirty = false;
for (let attempt = 0; attempt < 3; attempt++) {
const remote = await apiFetch('/accounts/vault', { headers: headers(session), signal: session.controller.signal }).then((r) => r.json() as Promise<VaultBody>);
const profile = validateVault(await openJson(remote.vault, session.key));
if (profile.keys.publicKey.x !== session.profile.keys.publicKey.x || profile.keys.publicKey.y !== session.profile.keys.publicKey.y) throw new Error('账号邀请密钥发生冲突,已停止同步');
const merged = mergeVault(profile, await listAllResources(), await listResourceDeletes(), session.profile.personal);
if (JSON.stringify(merged) === JSON.stringify(profile)) {
stillActive(session); await applyProfile(merged);
session.profile = merged; session.revision = remote.revision; session.error = undefined;
setAccountIdentity(session, merged.keys.publicKey, merged.personal);
await persistSession(session, remote);
break;
}
const vault = await sealJson(merged, session.key);
stillActive(session);
try {
const result = await apiFetch('/accounts/vault', { method: 'PUT', headers: { ...headers(session), 'Content-Type': 'application/json' },
signal: session.controller.signal, body: JSON.stringify({ revision: remote.revision, vault }) }).then((r) => r.json() as Promise<VaultBody>);
stillActive(session);
await applyProfile(merged);
session.profile = merged; session.revision = result.revision; session.error = undefined;
setAccountIdentity(session, merged.keys.publicKey, merged.personal);
await persistSession(session, { vault, revision: result.revision });
break;
} catch (error) {
if (attempt === 2 || !(error instanceof Error && 'status' in error && error.status === 409)) throw error;
}
}
} while (session.dirty && account === session);
})().catch((error: unknown) => {
if (account === session) session.error = error instanceof Error ? error.message : '同步失败';
if (account === session && error instanceof Error && 'status' in error && error.status === 401) {
account = undefined; clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort();
setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null);
void forgetAccount();
void forgetLoginFields();
}
throw error;
}).finally(() => { session.syncing = undefined; });
return session.syncing;
}
async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody, remember = true): Promise<AccountSession> {
const profile = validateVault(await openJson(remote.vault, key));
clearResourceSessions(); setResourceScope(identity.username);
const session: AccountSession = { ...identity, key, profile, revision: remote.revision, remember, controller: new AbortController(), dirty: false };
account = session; setAccountIdentity(identity, profile.keys.publicKey, profile.personal);
await applyProfile(mergeVault(profile, await listAllResources(), await listResourceDeletes()));
setResourceChangeListener(scheduleSync);
session.polling = setInterval(scheduleSync, 20_000);
return session;
}
export function restoreAccount(): Promise<void> {
restoration ??= (async () => {
const saved = await rememberedAccount();
if (!saved || account) return;
let remote: VaultBody;
try {
remote = await apiFetch('/accounts/vault', { headers: headers(saved) }).then((r) => r.json() as Promise<VaultBody>);
} catch (error) {
if (error instanceof Error && 'status' in error && error.status === 401) { await forgetAccount(); await forgetLoginFields(); return; }
remote = saved;
}
try {
const session = await activate(saved, saved.key, remote);
const local = validateVault(await openJson(saved.vault, saved.key));
if (local.personal && local.personal.modifiedAt > (session.profile.personal?.modifiedAt ?? -1)) session.profile = { ...session.profile, personal: local.personal };
setAccountIdentity(session, session.profile.keys.publicKey, session.profile.personal);
await syncVault().catch(() => {});
} catch (error) { await logoutAccount(); throw error; }
})().catch((error: unknown) => { restoration = undefined; throw error; });
return restoration;
}
function scheduleSync(): void {
if (!account) return;
account.dirty = true; clearTimeout(account.timer);
account.timer = setTimeout(() => { void syncVault().catch(() => {}); }, 700);
}
export async function registerAccount(input: string, password: string, remember = true): Promise<Awaited<ReturnType<typeof accountStatus>>> {
if (account) throw new Error('请先退出当前账号');
const username = usernameOf(input);
if (password.length < 12 || password.length > 1024) throw new Error('密码长度需为 12–1024 个字符');
const salt = crypto.getRandomValues(new Uint8Array(16));
const { verifier, key } = await deriveAccountKeys(password, salt);
const profile: VaultProfile = { v: 2, keys: await generateInvitationKeys(), resources: [], deletes: [] };
try {
await apiFetch('/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, salt: b64(salt), verifier: b64(verifier), public_key: JSON.stringify(profile.keys.publicKey), vault: await sealJson(profile, key) }) });
} finally { verifier.fill(0); }
return loginAccount(username, password, remember);
}
export async function loginAccount(input: string, password: string, remember = true): Promise<Awaited<ReturnType<typeof accountStatus>>> {
if (account) throw new Error('请先退出当前账号');
const username = usernameOf(input);
const challenge = await apiFetch('/accounts/salt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) })
.then((r) => r.json() as Promise<{ salt: string }>);
const { verifier, key } = await deriveAccountKeys(password, unb64(challenge.salt));
let identity: { username: string; token: string };
try {
const device = await localAccountDevice();
identity = await apiFetch('/accounts/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, verifier: b64(verifier), device_id: device.id, device_name: device.name }) })
.then((r) => r.json() as Promise<{ username: string; token: string }>);
} finally { verifier.fill(0); }
try {
const remote = await apiFetch('/accounts/vault', { headers: headers(identity) }).then((r) => r.json() as Promise<VaultBody>);
const profile = validateVault(await openJson(remote.vault, key));
const publicKey = await apiFetch(`/accounts/users/${username}/key`, { headers: headers(identity) }).then((r) => r.json() as Promise<{ public_key: string }>);
const published = JSON.parse(publicKey.public_key) as JsonWebKey;
if (published.x !== profile.keys.publicKey.x || published.y !== profile.keys.publicKey.y) throw new Error('账号邀请公钥与保险库不匹配');
const session = await activate(identity, key, remote, remember);
if (remember) await saveLoginFields(username, password); else await forgetLoginFields();
await persistSession(session, remote);
await syncVault().catch(() => {});
return accountStatus();
} catch (error) {
await logoutAccount();
void apiFetch('/accounts/session', { method: 'DELETE', headers: headers(identity) }).catch(() => {});
throw error;
}
}
export async function accountStatus(): Promise<{ username: string | null; revision: number; remembered: boolean; personal?: PersonalProfile; error?: string }> {
return { username: account?.username ?? null, revision: account?.revision ?? 0,
remembered: account?.remember ?? false,
...(account?.profile.personal ? { personal: account.profile.personal } : {}),
...(account?.error ? { error: '账号资源同步失败,请重试。' } : {}) };
}
export async function logoutAccount(): Promise<void> {
const session = account; account = undefined;
if (session) { clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort(); await session.syncing?.catch(() => {}); }
setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null);
await forgetAccount();
if (session) void apiFetch('/accounts/session', { method: 'DELETE', headers: headers(session) }).catch(() => {});
}
export async function syncAccount(): Promise<void> { await syncVault(); }
export async function getSavedLogin(): Promise<{ username: string; password: string; saved: boolean }> {
if (account) return { username: '', password: '', saved: false };
const fields = await savedLoginFields(); return fields ? { ...fields, saved: true } : { username: '', password: '', saved: false };
}
export async function clearSavedLogin(): Promise<void> {
if (account) {
account.remember = false;
await account.syncing?.catch(() => {});
}
await forgetAccount(); await forgetLoginFields();
}
export async function updatePersonal(displayName: string, avatar?: string): Promise<void> {
if (!account) throw new Error('请先登录账号');
account.profile = { ...account.profile, personal: validatePersonal({ displayName, ...(avatar ? { avatar } : {}), modifiedAt: Date.now() }) };
setAccountIdentity(account, account.profile.keys.publicKey, account.profile.personal);
// Retain an offline profile edit for the next session restoration.
await persistSession(account, { vault: await sealJson(account.profile, account.key), revision: account.revision });
await syncVault();
}
export async function listAccountDevices(): Promise<unknown> { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json()); }
export async function renameAccountDevice(id: string, name: string): Promise<void> {
requireAccount(); await apiFetch(`/accounts/devices/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
if ((await localAccountDevice()).id === id) await saveDeviceName(name.trim());
}
export async function revokeAccountDevice(id: string): Promise<void> {
requireAccount(); await apiFetch(`/accounts/devices/${id}`, { method: 'DELETE' });
if ((await localAccountDevice()).id === id) await logoutAccount();
}
export async function listAccountInvitations(): Promise<Invitation[]> {
requireAccount(); return apiFetch('/accounts/invitations').then((r) => r.json() as Promise<Invitation[]>);
}
export async function acceptInvitation(channelId: string): Promise<void> {
if (!account) throw new Error('请先登录账号');
const session = account;
const invitation = (await listAccountInvitations()).find((row) => row.channel_id === channelId);
if (!invitation) throw new Error('邀请已失效');
const link = await openChannelGrant(invitation.payload, session.username, channelId, session.profile.keys.privateKey, 'member', self.location.origin);
stillActive(session); await joinChannel({ link }); await syncVault();
}
export async function inviteChannelUser(channelId: string, input: string): Promise<void> {
requireAccount(); const username = usernameOf(input); const { session, resource } = await getSession(channelId);
if (!session.can('owner')) throw new Error('只有空间主可以邀请用户');
const body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise<{ public_key: string }>);
const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey);
await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload }) });
}
export async function listChannelInvitations(channelId: string): Promise<Invitation[]> {
const { session } = await getSession(channelId); return apiFetch(`/channels/${channelId}/invitations`, { headers: session.authHeaders('owner') }).then((r) => r.json() as Promise<Invitation[]>);
}
export async function revokeChannelInvitation(channelId: string, input: string): Promise<void> {
const { session } = await getSession(channelId); await apiFetch(`/channels/${channelId}/invitations/${usernameOf(input)}`, { method: 'DELETE', headers: session.authHeaders('owner') });
}
@@ -0,0 +1,45 @@
import { ChannelSession } from '@webdrop/core';
import { getResource, importResources, listAllResources, listResourceDeletes, type SavedResource } from '../database';
import { openChannelGrant, sealChannelGrant } from './channelGrant';
import { makeLink } from './links';
import { apiFetch, readJson } from './http';
import type { InvitationKeyPair } from './accountCrypto';
import type { ChannelWire } from './wire';
interface AccountChannel { channel_id: string; role: 'owner' | 'member'; payload: string; created_at: number }
export async function recoverAccountChannels(username: string, keys: InvitationKeyPair, signal: AbortSignal): Promise<void> {
const grants = await apiFetch('/accounts/channels', { signal }).then((r) => r.json() as Promise<AccountChannel[]>);
const deletes = new Map((await listResourceDeletes()).map((row) => [row.id, row.modifiedAt]));
const known = new Set(grants.map((row) => row.channel_id));
for (const grant of grants) {
if (signal.aborted) throw new DOMException('账号已切换', 'AbortError');
if ((deletes.get(grant.channel_id) ?? -1) >= grant.created_at || await getResource(grant.channel_id)) continue;
const parsed = await openChannelGrant(grant.payload, username, grant.channel_id, keys.privateKey, grant.role, self.location.origin);
const session = await ChannelSession.open(makeLink(self.location.origin, parsed.id, parsed.keys));
try {
const info = await readJson<ChannelWire>(`/channels/${session.id}`, session.authHeaders('read'));
session.verifyChannelInfo(info);
if (grant.role === 'owner' && info.creator_account !== username) throw new Error('空间所属账号不匹配');
const resource: SavedResource = { id: session.id, kind: 'channel', mode: session.mode, role: grant.role,
creatorAccount: info.creator_account, name: info.settings.name,
link: session.link(self.location.origin), ...(grant.role === 'owner' ? { ownerLink: session.link(self.location.origin, 'owner') } : {}),
endpointSecret: new Uint8Array(32), createdAt: info.created_at, modifiedAt: grant.created_at, expiresAt: info.expires_at,
seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload, maxDownloads: info.settings.file_max_downloads };
if (signal.aborted) throw new DOMException('账号已切换', 'AbortError');
await importResources([resource]);
} finally { session.dispose(); }
}
// Populate owner grants for channels created before account-bound delivery existed.
for (const resource of await listAllResources()) {
if (resource.kind !== 'channel' || resource.role !== 'owner' || !resource.ownerLink || known.has(resource.id)) continue;
const session = await ChannelSession.open(resource.ownerLink, { endpointSecret: resource.endpointSecret });
try {
const payload = await sealChannelGrant(resource.ownerLink, username, keys.publicKey);
await apiFetch(`/channels/${resource.id}/key-package`, { method: 'PUT', signal,
headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ payload }) });
} catch (error) {
if (!(error instanceof Error && 'status' in error && error.status === 404)) throw error;
} finally { session.dispose(); }
}
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { decryptInvitation, encryptInvitation, generateInvitationKeys, openJson, sealJson } from './accountCrypto';
describe('encrypted invitation recipient binding', () => {
it('only the addressed account and channel can decrypt the member link', async () => {
const bob = await generateInvitationKeys(); const carol = await generateInvitationKeys();
const payload = await encryptInvitation('https://drop.test/c/channel#k=secret', 'bob', 'channel', bob.publicKey);
expect(payload).not.toContain('secret');
expect(await decryptInvitation(payload, 'bob', 'channel', bob.privateKey)).toContain('#k=secret');
await expect(decryptInvitation(payload, 'carol', 'channel', carol.privateKey)).rejects.toThrow();
await expect(decryptInvitation(payload, 'bob', 'other-channel', bob.privateKey)).rejects.toThrow();
});
it('modified encrypted vault contents are rejected', async () => {
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
const encrypted = await sealJson({ resources: [] }, key);
const raw = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0)); raw[13] = (raw[13] ?? 0) ^ 1;
const corrupted = btoa(String.fromCharCode(...raw));
await expect(openJson(corrupted, key)).rejects.toThrow('校验失败');
});
});
@@ -0,0 +1,67 @@
const encoder = new TextEncoder();
const vaultLabel = 'webdrop-account-vault-v1';
const invitationLabel = 'webdrop-invitation-v2';
export function bytes(value: Uint8Array): ArrayBuffer { return Uint8Array.from(value).buffer; }
export function b64(value: Uint8Array): string {
let text = '';
for (let i = 0; i < value.length; i += 32768) text += String.fromCharCode(...value.subarray(i, i + 32768));
return btoa(text);
}
export function unb64(value: string): Uint8Array { return Uint8Array.from(atob(value), (c) => c.charCodeAt(0)); }
export async function deriveAccountKeys(password: string, salt: Uint8Array): Promise<{ verifier: Uint8Array; key: CryptoKey }> {
if (salt.length !== 16 || password.length > 1024) throw new Error('账号凭据格式无效');
const base = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveBits']);
const root = new Uint8Array(await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: bytes(salt), iterations: 600_000 }, base, 256));
try {
const hmac = await crypto.subtle.importKey('raw', root, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const verifier = new Uint8Array(await crypto.subtle.sign('HMAC', hmac, encoder.encode('webdrop-account-login-v1')));
const hkdf = await crypto.subtle.importKey('raw', root, 'HKDF', false, ['deriveKey']);
const key = await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: bytes(salt), info: encoder.encode(vaultLabel) }, hkdf,
{ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
return { verifier, key };
} finally { root.fill(0); }
}
export async function sealJson(value: unknown, key: CryptoKey, context = vaultLabel): Promise<string> {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, additionalData: encoder.encode(context) }, key, encoder.encode(JSON.stringify(value))));
const result = new Uint8Array(nonce.length + cipher.length); result.set(nonce); result.set(cipher, 12);
if (result.length > 1_300_000) throw new Error('账号保险库已达到大小上限');
return b64(result);
}
export async function openJson(value: string, key: CryptoKey, context = vaultLabel): Promise<unknown> {
const input = unb64(value);
if (input.length < 28 || input.length > 1_300_000) throw new Error('加密数据格式无效');
try {
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: bytes(input.subarray(0, 12)), additionalData: encoder.encode(context) }, key, bytes(input.subarray(12)));
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(plain));
} catch { throw new Error('加密数据校验失败,已拒绝导入'); }
}
export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey }
export async function generateInvitationKeys(): Promise<InvitationKeyPair> {
const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
return { publicKey: await crypto.subtle.exportKey('jwk', pair.publicKey), privateKey: await crypto.subtle.exportKey('jwk', pair.privateKey) };
}
async function invitationKey(privateKey: JsonWebKey, publicKey: JsonWebKey, context: string): Promise<CryptoKey> {
const own = await crypto.subtle.importKey('jwk', privateKey, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']);
const peer = await crypto.subtle.importKey('jwk', publicKey, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: 'ECDH', public: peer }, own, 256));
try {
const root = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']);
return await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: encoder.encode(context), info: encoder.encode(invitationLabel) }, root,
{ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
} finally { shared.fill(0); }
}
export async function encryptInvitation(link: string, recipient: string, channelId: string, publicKey: JsonWebKey): Promise<string> {
const pair = await generateInvitationKeys(); const context = `${invitationLabel}:${recipient}:${channelId}`;
const key = await invitationKey(pair.privateKey, publicKey, context);
return JSON.stringify({ v: 2, publicKey: pair.publicKey, cipher: await sealJson({ link }, key, context) });
}
export async function decryptInvitation(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey): Promise<string> {
const envelope = JSON.parse(payload) as { v: number; publicKey: JsonWebKey; cipher: string };
if (envelope.v !== 2 || typeof envelope.cipher !== 'string') throw new Error('邀请格式无效');
const context = `${invitationLabel}:${recipient}:${channelId}`;
const key = await invitationKey(privateKey, envelope.publicKey, context);
const value = await openJson(envelope.cipher, key, context) as { link?: unknown };
if (typeof value.link !== 'string') throw new Error('邀请格式无效');
return value.link;
}
@@ -0,0 +1,45 @@
import { openDB, type DBSchema } from 'idb';
import { apiPath } from './http';
import { decryptLogin, encryptLogin, type EncryptedLogin, type LoginFields } from './credentialCipher';
export interface RememberedAccount {
username: string;
token: string;
key: CryptoKey;
vault: string;
revision: number;
}
interface LocalIdentity extends DBSchema {
credentials: { key: string; value: EncryptedLogin };
sessions: { key: string; value: RememberedAccount };
device: { key: string; value: { id: string; name: string } };
}
const database = () => openDB<LocalIdentity>('webdrop-account-local-2', 2, { upgrade(db, oldVersion) {
if (oldVersion < 1) { db.createObjectStore('sessions'); db.createObjectStore('device'); }
if (oldVersion < 2) db.createObjectStore('credentials');
} });
const endpoint = () => apiPath('/');
export async function rememberedAccount(): Promise<RememberedAccount | undefined> { return (await database()).get('sessions', endpoint()); }
export async function rememberAccount(value: RememberedAccount): Promise<void> { await (await database()).put('sessions', value, endpoint()); }
export async function forgetAccount(): Promise<void> { await (await database()).delete('sessions', endpoint()); }
export async function saveLoginFields(username: string, password: string): Promise<void> {
const value = await encryptLogin({ username, password }, endpoint()); await (await database()).put('credentials', value, endpoint());
}
export async function forgetLoginFields(): Promise<void> { await (await database()).delete('credentials', endpoint()); }
export async function savedLoginFields(): Promise<LoginFields | undefined> {
const value = await (await database()).get('credentials', endpoint()); if (!value) return undefined;
try { return await decryptLogin(value, endpoint()); } catch { await forgetLoginFields(); return undefined; }
}
export async function localAccountDevice(): Promise<{ id: string; name: string }> {
const db = await database();
const existing = await db.get('device', endpoint());
if (existing) return existing;
const name = /Android/i.test(navigator.userAgent) ? 'Android 浏览器' : /iPhone|iPad/i.test(navigator.userAgent) ? 'Apple 移动浏览器'
: /Windows/i.test(navigator.userAgent) ? 'Windows 浏览器' : /Mac/i.test(navigator.userAgent) ? 'Mac 浏览器' : '浏览器设备';
const value = { id: crypto.randomUUID(), name };
await db.put('device', value, endpoint());
return value;
}
export async function saveDeviceName(name: string): Promise<void> {
const device = await localAccountDevice(); await (await database()).put('device', { ...device, name }, endpoint());
}
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff. Show more