27 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
justme a182043970 ci: cache native image build dependencies
ci/woodpecker/manual/ci Pipeline failed
ci/woodpecker/manual/release-backend Pipeline was canceled
2026-10-02 11:51:56 +08:00
justme 2150849529 ci: use host network for Buildah image build
ci/woodpecker/manual/ci Pipeline was successful
ci/woodpecker/manual/release-backend Pipeline was successful
2026-10-02 11:39:48 +08:00
justme dde7beee15 ci: publish releases through internal Zot endpoint
ci/woodpecker/manual/ci Pipeline was successful
ci/woodpecker/manual/release-backend Pipeline failed
2026-10-02 11:37:24 +08:00
justme dd6ab78353 ci: checkout tagged source for manual releases
ci/woodpecker/manual/release-backend Pipeline failed
ci/woodpecker/manual/ci Pipeline was successful
2026-10-02 11:34:30 +08:00
justme 9964b4e3ad ci: allow manual semver image publishing 2026-10-02 11:33:34 +08:00
203 changed files with 7947 additions and 1102 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_BIND_ADDRESS=127.0.0.1
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_REQUESTS_PER_MINUTE=600
+2
View File
@@ -21,6 +21,8 @@ server/.idea/
!.env.example
# Editor and OS files
.idea/
.playwright-mcp/
.vscode/
.DS_Store
Thumbs.db
+39 -45
View File
@@ -1,45 +1,39 @@
when:
- event: [push, 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:
api-contract:
image: bash
commands:
- 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:
image: bash
commands:
- dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal
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:
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:
image: bash
commands:
- node scripts/check-api-contract.mjs
server:
image: bash
depends_on: [prefetch-dependencies]
when:
- evaluate: 'not (CI_COMMIT_MESSAGE contains "[SKIP TESTS]")'
commands:
- 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"
+37 -10
View File
@@ -1,6 +1,7 @@
when:
- event: tag
ref: refs/tags/v*
- event: manual
labels:
ci: domain-linux-native
@@ -15,24 +16,50 @@ clone:
lfs: false
steps:
checkout-release-source:
image: bash
commands:
- set -euo pipefail
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
- test -n "$release_tag"
- node scripts/check-api-contract.mjs "$release_tag"
- git fetch --force origin "refs/tags/$release_tag:refs/tags/$release_tag"
- git checkout --detach "refs/tags/$release_tag"
release-checks:
image: bash
commands:
- node scripts/check-api-contract.mjs "$CI_COMMIT_TAG"
- dotnet test server/WebDrop.slnx --configuration Release --verbosity minimal
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
- test -n "$release_tag"
- node scripts/check-api-contract.mjs "$release_tag"
- 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:
image: bash
environment:
ZOT_USERNAME:
from_secret: registry_username
ZOT_PASSWORD:
from_secret: registry_password
ZOT_REGISTRY: zot.oeo.one
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
ZOT_REGISTRY: registry.oeo.one
commands:
- set -euo pipefail
- printf '%s' "$ZOT_PASSWORD" | buildah login "$ZOT_REGISTRY" --username "$ZOT_USERNAME" --password-stdin
- release_tag="${CI_COMMIT_TAG:-${RELEASE_TAG:-}}"
- test -n "$release_tag"
- ': "${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
- image="$ZOT_REGISTRY/justme/webdrop-api:$CI_COMMIT_TAG"
- buildah bud --format docker --platform linux/amd64 --tag "$image" --file server/Dockerfile .
- image="$ZOT_REGISTRY/justme/webdrop-api:$release_tag"
- latest_image="$ZOT_REGISTRY/justme/webdrop-api:latest"
- 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/`) |
| `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 个测试,含对着真实服务端的端到端 |
| `web/` | 前端,开发中 |
@@ -27,7 +27,7 @@
| [web/AGENTS.md](web/AGENTS.md) | 前端契约:权威顺序、范围、必须遵守与禁止项 |
| [packages/core/README.md](packages/core/README.md) | `@webdrop/core` 的接口、前端要做和不用做的事、必须注意的几点 |
| [server/README.md](server/README.md) | 服务端的结构、配置、AOT 发布、验证码(Cap)接入 |
| [docs/deployment.md](docs/deployment.md) | Docker Compose 自托管部署与 Woodpecker CI |
| [docs/deployment.md](docs/deployment.md) | Podman Quadlet 自托管部署与 Woodpecker CI |
| [test-vectors/README.md](test-vectors/README.md) | 协议测试向量的生成与使用 |
## 核心概念
@@ -45,6 +45,6 @@ webdrop-proj/
├─ docs/
├─ test-vectors/ 协议测试向量,所有实现共用
├─ packages/core/ @webdrop/core,TS,加解密与帧编解码(网页、将来的 CLI 共用)
├─ server/ .NET 10,NativeAOT
├─ server/ .NET 10,NativeAOT;可将 Vite dist 嵌入 WebDrop.Frontend
└─ 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 白名单。
@@ -10,8 +12,8 @@
| `Authorization` | `Bearer <base64url(令牌)>` |
| `X-WD-Endpoint` | `base64url(S)`,终端密钥,用于席位 |
| `X-WD-Cap` | 创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11) |
| `X-WD-Label` | raw 模式条目标签,≤255 B |
| `X-WD-Max-Downloads` | 第 0 帧上带,条目的下载上限,0 为不限 |
| `X-WD-Account` | 账号 session token。与频道/分享的能力令牌分开;创建、管理和账号成员请求必填,访客省略 |
raw 模式的读请求不需要令牌,但仍需要 `X-WD-Endpoint`。
@@ -28,17 +30,32 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
## 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`
返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。
### `POST /api/v1/channels`
创建频道。服务端启用验证码时需要 `X-WD-Cap`;验证服务不可用时返回 `503 captcha_unavailable`,与「用户没通过验证」(`428 captcha_required`)区分。
创建者必须登录。只有一套频道/成员/线程模型,`mode=enc|raw` 仅决定客户端内容是否端到端加密,不改变业务权限。创建者自动写入该频道的 owner 成员记录并占一个设备席位。服务端启用验证码时还需要 `X-WD-Cap`。
```json
{
"id": "<channel_id>",
"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 },
"read_hash": "<sha256(T_read)>",
"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`)。
### `GET /api/v1/channels/{id}`
返回 `ChannelInfo`:设置、模式、席位占用、`oldest_seq`、`last_seq`、时间。**不占席位**,任意角色可调用,用于加入前预览和核对模式。
返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、席位占用、`oldest_seq`、`last_seq`、时间。**不占席位**,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。
### `POST /api/v1/channels/{id}/seats`
加入频道,占用一个席位。幂等:同一终端重复调用返回同一席位。席位已满返回 `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`
所有者修改设置。`ttl_seconds` 的修改从现在起算。
所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`ttl_seconds` 的修改从现在起算。
### `DELETE /api/v1/channels/{id}`
所有者删除整个频道和全部密文。
@@ -96,8 +119,9 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
### 席位
- `GET /api/v1/channels/{id}/seats`:所有者查看(终端哈希、加密设备名、加入时间)。
- `PUT /api/v1/channels/{id}/seats/me`:终端上传加密设备名(≤256 B)。
- `DELETE /api/v1/channels/{id}/seats/{endpoint_hash}`:所有者释放席位。
- `PUT /api/v1/channels/{id}/seats/me`:终端上传设备名(≤256 B);enc 模式加密,raw 模式明文。
- `PUT /api/v1/channels/{id}/seats/{endpoint_hash}/label`:所有者为指定设备更新名称;enc 模式的密文绑定目标 endpoint hash,raw 模式明文。设备也可用 `/seats/me` 更新自己的名称。
- `DELETE /api/v1/channels/{id}/seats/{endpoint_hash}`:所有者移除该设备的席位,其活动订阅随后会被拒绝。
## 4. 便捷分享接口
@@ -189,3 +213,15 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
| `captcha_required` | 428 | 需要验证码:缺少 `X-WD-Cap`,或 token 无效、已用过 |
| `captcha_unavailable` | 503 | 验证码服务暂时不可用(服务端到 Cap 的调用失败),可稍后重试 |
| `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 合并,不写入服务端明文账号表。昵称不改变固定用户名或参与密钥派生。
+26 -12
View File
@@ -2,20 +2,22 @@
## 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 │
│ @webdrop/core (加解密、帧) │ 密文 + 令牌 │ 鉴权: 只比对令牌哈希 │
│ Worker: 流式加解密 │◄───────────►│ 日志: 分配 seq、幂等、推送 │
│ IndexedDB: 发送队列、本地收藏 │ │ BackgroundService: 过期清理 │
│ Service Worker: 流式下载 │ └──────┬───────────┬──────────┘
│ OPFS: 逐块写入临时文件 │ └──────┬───────────┬──────────┘
└──────────────────────────────┘ │ │
SQLite IBlobStore
(元数据、日志) (磁盘,预留 S3)
SQLite IBlobStore
(频道、账号密文库) (磁盘,预留 S3)
```
服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。
服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。可选的 `WebDrop.Frontend` 模块只提供嵌入的静态资源,不访问频道存储或加解密代码。
## 2. 技术栈
@@ -42,7 +44,7 @@
| 全球化 | `InvariantGlobalization=true` |
| 库 | 所有项目标记 `IsAotCompatible=true`,把 IL2026、IL3050 等 AOT 和裁剪警告视为错误 |
| 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):
- Slim builder、Minimal API、原生 WebSocket、SSE、`BackgroundService`、`Microsoft.Data.Sqlite`(随包的 e_sqlite3 原生库,SQLite 3.53)在 NativeAOT 下都能正常运行。
@@ -55,11 +57,17 @@
## 4. 存储模型
应用契约 2 使用 `DataDir/v2/webdrop.db` 和 `DataDir/v2/blobs`,仅包含当前表结构,不维护旧 schema/旧账号保险库/旧频道认领/旧明文负载的迁移或兼容模型。开发数据可以重新创建,历史目录不自动导入。浏览器采用新的按账号/访客隔离的数据库命名空间。
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` |
| `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`),创建者终端不写入此表 |
@@ -84,8 +92,9 @@ SQLite 存元数据和小帧,磁盘存大帧。
## 5. 部署
- API 与前端使用**不同域名**:前端静态资源可以放在 CDN 或独立静态主机,API 被攻破不影响前端。
- 前端构建产物为所有脚本和样式生成 SRI,发布时公布构建哈希。
- 安全隔离部署时,API 与前端使用**不同域名**:前端静态资源可以放在独立静态主机,API 被攻破不影响前端代码。
- Compose 单机模式可以把 Vite dist 嵌入 `WebDrop.Frontend`,由 API 同源提供页面;此模式简化部署,但 API 主机也是前端代码的信任根。
- 独立托管时,前端资源应使用 HTTPS 发布;若部署 SRI,发布时公布构建哈希。
- 响应头:`Referrer-Policy: no-referrer`、严格 CSP、`X-Content-Type-Options: nosniff`。
- 反向代理不得记录请求头(令牌)和完整 URL。
- 服务端日志不含频道 id 明文,只含截断后的哈希。
@@ -96,8 +105,10 @@ SQLite 存元数据和小帧,磁盘存大帧。
- 加解密在 Worker 里流式处理,文件不整份进内存。
- 发送队列落在 IndexedDB,恢复网络后自动补发,依赖协议的幂等追加。
- 条目预览在沙箱 iframe 中渲染;raw 内容只当文本展示或下载。
- 下载大文件使用 Service Worker 流式写入,兼容没有 File System Access API 的 iOS Safari。
- 本地「我的频道」:链接和密钥保存在 IndexedDB,只在本机。
- 下载大文件优先使用 OPFS 流式写入临时文件,认证完成后提供文件;不支持 OPFS 时回退 Blob,限制 64 MiB。
- 首页先注册/登录;公开频道/分享链接可按规则由访客访问。创建与管理在服务端绑定账号,频道内成员角色与设备席位分开。所有写入都检查账号或访客设备、频道角色/席位和能力权限。
- IndexedDB 按账号与访客分库;切换时中止后台订阅并清空内存。保险库只同步资源凭据、草稿清单和移除墓碑;设备 S、设备标签、缓存、原始文件和 outbox 保留在本机。新设备生成独立 S 后重新加入。
- 一个 Channel/Thread 模型共用日志、权限与生命周期;内容是否端到端加密只切换客户端封装加解密。结构 CONTROL 记录不按普通内容 TTL/max_items 清理,随频道删除。
## 7. 阶段划分
@@ -110,7 +121,8 @@ SQLite 存元数据和小帧,磁盘存大帧。
| 4 | 文件:流式加解密、GATED 拉取、下载计数、预览、粘贴和拖拽 | 100 MB 文件往返一致,下载计数正确 |
| 5 | GB 级文件:并行上传和断点续传 | 中途断网后续传成功,内存占用平稳 |
| 6 | PWA、Web Share Target、二维码和短配对码(PAKE) | 手机端走通 |
| 7 | 可选账号、配额;CLI | 另行设计 |
| 7 | 账号登录、客户端加密资源保险库 | 已实现客户端派生口令 verifier/保险库密钥、SQLite opaque vault、资源同步;口令丢失时明确告知无法恢复 |
| 8 | thread、虚拟文件夹与 IRC 风格聊天 | 已实现沿用频道加密日志,消息按 thread 归属并展示发送设备,文件夹元数据由客户端加密 |
每个阶段开始前,先更新 protocol.md 和测试向量,再改代码。
@@ -121,3 +133,5 @@ SQLite 存元数据和小帧,磁盘存大帧。
- 服务端日志中没有 K、令牌和频道 id 明文。
- 抓包确认请求里没有 K 和明文。
- 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
Copy-Item .env.example .env
docker compose config
docker compose up -d --build
docker compose ps
.\scripts\update-wsl-podman.ps1
```
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`。管理服务:
## 验证码
默认 `CAPTCHA_PROVIDER=none`。启用 Cap 时,在 `.env` 设置:
```dotenv
CAPTCHA_PROVIDER=cap
CAPTCHA_API_ENDPOINT=https://captcha.example.com/<site-key>/
CAPTCHA_SITEVERIFY_URL=https://captcha.example.com/<site-key>/siteverify
CAPTCHA_SECRET=<server-side-secret>
```bash
systemctl --user status webdrop.service
systemctl --user stop webdrop.service
systemctl --user start webdrop.service
```
`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.yml` 在 push 和手动运行时执行 API 契约、Core、Web、服务端检查;每次 push 都会构建前端。`.woodpecker/release-backend.yml` 仅响应 `vMAJOR.MINOR.PATCH` SemVer tag;测试通过后用 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。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 仓库配置 tag 事件可用的 secrets `registry_username`、`registry_password`。发布步骤要求 Linux native agent 安装 Buildah;使用已发布镜像时,在 `.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` | |
| UI | React 19.3(稳定版,含 `ViewTransition`、`Activity`) | 不使用 canary |
| 状态 | Zustand | 按领域拆 store |
| 本地存储 | `idb`(IndexedDB) | 发送队列、「我的频道」、草稿预览所需的本机文件 |
| 本地存储 | `idb`(IndexedDB) | 发送队列、「我的频道」、账号资源工作副本、草稿预览所需的本机文件 |
| 样式 | 原生 CSS + CSS 变量 + CSS Modules | 无 Tailwind、无 CSS-in-JS |
| 路由 | 自写极简导航(`app/navigation.ts`) | 只有两三个路由,且需统一挂 view transition |
| 测试 | Vitest(单元)、Playwright(端到端) | |
@@ -20,6 +20,8 @@
**依赖策略**:新增运行时依赖需要书面理由(解决什么、为何不能自写、体积)。不引入 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. 目录与分层
```
@@ -91,6 +93,8 @@ type ItemState =
| 降级防御 | 链接含 `k` 就只接受 enc 且认证通过的帧 |
| 不把密钥、令牌、明文写入 `console`、错误上报、URL、`localStorage` | 「我的频道」只存 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` |
| 严格 CSP,所有脚本样式带 SRI,不使用内联脚本 | 与 architecture §5 一致 |
| 不引入任何第三方脚本、统计、字体 CDN | 字体自托管 |
@@ -157,18 +161,38 @@ type ItemState =
在 `@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 / 协议 |
|---|---|
| 新建频道或分享 | `ChannelSession.create({ mode, settings, endpointSecret })` → `POST /channels`;分享的 `settings` 为 `members_can_upload=false`、`delete_policy=owner_only`,并设 `seats`、`ttl_seconds`、`file_max_downloads` |
| 草稿阶段调整设置 | `PATCH /channels/{id}/settings`(需所有者令牌) |
| **完成分享** | 客户端丢弃所有者密钥 A:用成员链接重新 `ChannelSession.open(...)`,此后 `session.can('owner')` 为 false。丢弃是客户端的自觉行为,服务端不知道;若要强制,需后端提供「封存」让服务端作废 `owner_hash`(属于后端与协议改动,前端只提请求) |
| 文件下载次数 | 每个条目的 `maxDownloads` 会强制 GATED,并带 `X-WD-Max-Downloads`,**必须随第 0 帧发送**。文本不要设,否则读文本也要拉取 |
| 内联渲染不烧次数 | GATED 条目每次 `GET /items/{id}/frames?from=0` 计一次下载;自动加载只用于非 GATED(见 ui-design §6.5) |
| 终端密钥 | `session.endpointSecret` 必须持久化到 IndexedDB,否则每次打开都占一个新席位。原型暂未做 |
| 创建频道、便捷分享的验证码 | 读 `GET /info` 的 `captcha`;需要时按需加载打包在前端的官方 `cap-widget`,求解后只把一次性 token 放进创建请求的 `X-WD-Cap`。不从 CDN 加载脚本 |
| 校验失败 | reader 的 `rejected` 事件不能忽略,至少在界面提示「内容校验失败」 |
| Worker | `ChannelSession` 不能跨线程,在 Worker 里用链接和 `endpointSecret` 重建 |
@webdrop/core 仅实现纯密码学与帧协议,不访问数据库或网络。sessions.ts 管本机已解锁会话。transfer.ts 管持久化密文发送队列,队列按资源/创建顺序索引,确保文件密文帧先于目录记录上传。downloadStorage.ts 优先使用 OPFS 逐块写入临时文件,认证结束后交付;不支持时最多 64 MiB 的 Blob 回退。
频道和 Share 各有独立领域 API 与生命周期;Share 封存由服务端事务清除 owner_hash,不能再写。账号保险库同步个人资料、邀请私钥、资源凭据和移除墓碑;设备 S、文件缓存与 outbox 保留本机。
## 13. 账号会话恢复与频道密钥领取
本机 IndexedDB 保存账号 session token、不可导出的保险库 CryptoKey 和最近验证的加密保险库,不保存密码或登录 verifier。启动时先恢复账号并验证服务端 session,再读取该账号的资源数据库;网络离线保留本机身份,服务端明确拒绝 session 时清除登录凭据。退出登录清除本机自动恢复记录。记住登录允许使用本浏览器的人访问账号资源。
登录/同步后按账号领取 /accounts/channels 密钥包,解密并核对频道 ID、模式和角色,再补齐本机频道;本机主动移除的资源墓碑不会被旧密钥包重新加入。新设备不复用设备 S。打开无 fragment 的频道地址时,已登录账号可以领取授权包,待接受邀请需用户确认加入。未授权时显示需要频道主邀请,不再只要求含密钥链接。
## 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 时删除。旧版保存的会话视为开启自动恢复,但没有原始密码,不伪造密码填充。
+379 -14
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: WebDrop API
version: 1.0.0
version: 2.1.0
servers:
- url: /api/v1
@@ -12,8 +12,79 @@ tags:
- name: frame
- name: item
- name: share
- name: account
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:
get:
tags: [server]
@@ -26,14 +97,183 @@ paths:
application/json:
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:
post:
tags: [channel]
security: []
security: [{ accountBearer: [] }]
summary: 创建频道
description: |
服务端启用验证码时需要 `X-WD-Cap`(Cap 的 token,见 protocol §11),并需要终端头 `X-WD-Endpoint`。
创建者的终端自动入席,占一个席位。
创建者的账号自动成为该频道 owner,其终端自动入席。
parameters:
- { $ref: '#/components/parameters/Captcha' }
- { $ref: '#/components/parameters/Endpoint' }
@@ -170,6 +410,30 @@ paths:
'403': { $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}:
delete:
tags: [seat]
@@ -196,7 +460,7 @@ paths:
description: |
请求体是完整的帧封装(26 字节头 + 负载),格式见 `protocol.md` §4。
幂等键为 `(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: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
@@ -207,11 +471,6 @@ paths:
required: false
schema: { type: integer, minimum: 0 }
description: 条目的下载上限,0 为不限。只对 GATED 条目生效。
- name: X-WD-Label
in: header
required: false
schema: { type: string, maxLength: 255 }
description: raw 模式的条目标签,明文。
requestBody:
required: true
content:
@@ -389,7 +648,7 @@ paths:
/shares:
post:
tags: [share]
security: []
security: [{ accountBearer: [] }]
summary: 创建便捷分享草稿
description: 启用验证码时需携带 Cap token。创建者 endpoint 可选,创建者不计入访客数。
parameters:
@@ -583,6 +842,11 @@ paths:
components:
securitySchemes:
accountBearer:
type: apiKey
in: header
name: X-WD-Account
description: WebDrop 账号登录后颁发的随机 session token。
bearer:
type: http
scheme: bearer
@@ -652,6 +916,88 @@ components:
schema: { $ref: '#/components/schemas/Error' }
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:
type: string
pattern: '^[A-Za-z0-9_-]+$'
@@ -675,6 +1021,13 @@ components:
- rate_limited
- visitors_full
- sealed
- account_required
- account_invalid
- invitation_required
- channel_password_required
- channel_password_invalid
- username_unavailable
- vault_conflict
Error:
type: object
@@ -691,10 +1044,10 @@ components:
ServerInfo:
type: object
required: [protocol, contract_version, limits, captcha, transports]
properties:
protocol: { type: integer, const: 1 }
contract_version: { type: string, example: 1.0.0, description: API 契约的 SemVer。破坏性变更递增主版本。 }
required: [protocol, contract_version, limits, captcha, transports]
properties:
protocol: { type: integer, const: 1 }
contract_version: { type: string, example: 2.0.0, description: API 契约的 SemVer。破坏性变更递增主版本。 }
captcha:
type: object
required: [provider, enabled]
@@ -731,6 +1084,9 @@ components:
type: object
required: [seats, members_can_upload, delete_policy, ttl_seconds, max_items, file_max_downloads, ui_restrict_copy_download]
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 }
members_can_upload: { type: boolean, default: true }
delete_policy: { $ref: '#/components/schemas/DeletePolicy' }
@@ -743,6 +1099,10 @@ components:
type: object
description: ChannelSettings 的全部字段均可选
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 }
members_can_upload: { type: boolean }
delete_policy: { $ref: '#/components/schemas/DeletePolicy' }
@@ -755,6 +1115,8 @@ components:
type: object
required: [id, mode, settings, owner_hash]
properties:
key_package: { type: string, minLength: 1, maxLength: 16384, description: 用创建者账号公钥封装的 owner 凭据 }
join_password: { type: string, minLength: 8, maxLength: 256 }
id: { $ref: '#/components/schemas/Base64Url' }
mode: { $ref: '#/components/schemas/ChannelMode' }
settings: { $ref: '#/components/schemas/ChannelSettings' }
@@ -803,6 +1165,7 @@ components:
type: object
required: [item_id, bytes, frames, max_downloads, downloads_left]
properties:
complete: { type: boolean }
item_id: { $ref: '#/components/schemas/Base64Url' }
bytes: { type: integer, format: int64 }
frames: { type: integer }
@@ -828,6 +1191,8 @@ components:
type: object
required: [id, mode, settings, seats_used, oldest_seq, last_seq, created_at, expires_at]
properties:
password_required: { type: boolean }
creator_account: { type: string }
id: { $ref: '#/components/schemas/Base64Url' }
mode: { $ref: '#/components/schemas/ChannelMode' }
settings: { $ref: '#/components/schemas/ChannelSettings' }
+41 -7
View File
@@ -12,18 +12,18 @@
|---|---|---|
| `channel_id` | 16 B | 随机,客户端生成 |
| `item_id` | 16 B | 随机,客户端生成 |
| `K` | 32 B | 频道密钥,仅 enc 模式,只存在于 URL fragment |
| `K` | 32 B | 频道密钥,仅 enc 模式,按账号加密封装;可选能力链接使用 fragment |
| `A` | 32 B | 管理密钥,独立随机,只存在于所有者链接 |
| `W` | 32 B | 写密钥,仅 raw 模式且成员可上传时 |
| `S` | 32 B | 终端密钥,每个频道每个终端各生成一个,仅存本地 |
## 2. 模式与链接
频道创建时二选一,之后不可更改。
只有一个频道模型。创建时可选择是否启用端到端加密,默认 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>` | `/c/{id}[#w=<W>]` |
| 读权限 | 持 `T_read` | 知道 id 即可(能力链接) |
@@ -70,7 +70,8 @@ raw 模式下 `T_owner` 同样由 `A` 派生;`T_write = W`。
| 1 | SNAPSHOT | 该条目是频道的「最新快照」,取代之前的快照条目 |
| 2 | GATED | 不通过实时推送内联分发,只能按条目拉取,并计下载次数 |
| 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` | 可选,内容总字节数(流式时可省略) |
| `ts` | 毫秒时间戳,仅供参考,排序以 seq 为准 |
| `dev` | 可选,设备名 |
| `thread_id` | 可选,频道 thread ID |
| `folder_id` | 可选,文件的虚拟文件夹 ID |
第 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。
## 8. 日志、序号与条目生命周期
`CONTROL=0x10` 标记频道结构控制条目;保留位为 `0xE0`。控制条目必须是非 GATED、非 SNAPSHOT 的 FINAL 单帧,payload 最多 16,400 B;其生命周期为频道生命周期,不受普通内容 TTL 或 max_items 淘汰。每频道最多 4096 条控制记录,仍计入总字节配额。加密和非加密频道使用相同控制正文与日志处理。
频道维护一条服务端排序的**日志**,每条记录的序号 `seq` 是 u64,频道内单调递增。
| kind | 值 | 内容 |
@@ -164,11 +171,12 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
- 终端通过显式的加入请求(`POST .../seats`)占用一个席位;创建者的终端在创建时自动入席。席位已满则拒绝新终端(`seats_full`),已入席的终端可重复加入(幂等)和重连。未入席的终端访问频道返回 `not_joined`。
- 不同频道使用不同的 `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。
## 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`。
@@ -180,10 +188,11 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
## 12. 设置
频道设置对服务端可见,服务端据此执行:
频道设置对服务端可见,服务端据此执行。`name` 是共享频道名,所有加入者都从频道信息中读取:
| 设置 | 默认 | 范围 |
|---|---|---|
| `name` | 无 | 可选,最多 48 个字符;所有频道成员和服务端可见 |
| `seats` | 2 | 1..64(服务端可配置上限) |
| `members_can_upload` | true | |
| `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/...`)中同时体现。
- `test-vectors/` 提供固定输入、固定输出:密钥派生、令牌、加解密、padding、封装编解码,以及篡改、重排、截断、换频道的负例,必须全部通过。
- 本版**预留**但不实现:口令叠加(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 |
| 帧标志 | FINAL、SNAPSHOT、GATED、ENC |
@@ -24,12 +24,13 @@
| 令牌的哈希、终端的哈希 | 不是原值,但每次请求中会短暂看到令牌原值 |
| 下载次数 | 服务端计数 |
| 传输层信息 | IP、TLS 指纹、访问时间 |
| 账号服务可见 | 用户名、口令 verifier、保险库密文大小和更新时间;不会看到保险库明文、频道密钥或分享链接 |
| 看不到(enc 模式) | 说明 |
|---|---|
| 内容 | 文本、JSON、文件字节 |
| 文件名、类型、总大小、设备名 | 都在加密的条目元数据里 |
| 密钥 K、管理密钥 A | 只在 URL fragment 中,不会发给服务器 |
| 密钥 K、管理密钥 A | 由客户端保管,以账号公钥封装的密文可存服务器;可选分享链接使用 fragment |
`raw` 模式下服务端能看到全部负载,界面必须明确提示。
@@ -56,16 +57,25 @@
| fragment 外泄 | 取出后立即 `history.replaceState`;`Referrer-Policy: no-referrer`;不把 K 放进查询串 |
| XSS | 严格 CSP;条目预览在沙箱中渲染;`raw` 内容不内联渲染,下载带 `attachment` 和 `nosniff` |
| 暴力猜频道 id | id 128 位随机 |
| 撞库/截获账号登录 | 客户端 PBKDF2 派生登录凭据,经 HTTPS 传输;服务端另存独立随机 salt 的 PBKDF2 慢哈希,并限流。TLS 或运行终端泄露仍可能暴露可登录凭据 |
| 账号数据库泄露 | 数据库只有加盐慢哈希,不能直接用于登录;弱口令仍可被离线猜测。登录凭据不能直接派生保险库密钥 |
| 账号混用/设备身份混用 | 按账号与访客隔离 IndexedDB,切换时中止后台任务;保险库不包含设备 S,每台设备单独生成 |
| 绕过频道加入规则 | 服务端检查 owner/member 角色、邀请、密码、账号 session 与设备席位;加密选项不会绕开权限 |
| 匿名滥用与 DoS | 创建时的 Cap 验证码(工作量证明由 Cap 提供)、限流、频道配额、条目数和字节数上限 |
## 5. 前端信任根(残余风险)
邀请密文使用收件账号的 P-256 公钥派生 AES-GCM 密钥,私钥在密码保护的保险库中。但首次邀请公钥来自服务端目录,目前没有外部指纹核验:恶意服务端替换公钥会影响新邀请的保密性。此限制与已有频道内容的密钥/帧认证边界不同。
端到端加密的前提是浏览器执行的是正确的代码。如果同一个被攻破的后端同时下发前端,它可以换成偷 K 的页面。
第一版的缓解:
- 前端静态资源托管在**独立于 API 的域名**,API 节点被攻破不影响前端。
- 所有脚本使用 SRI,构建产物带版本哈希,发布时公布哈希。
- 前端托管方被攻破的风险仍然存在。
隔离部署的缓解:
- 前端静态资源托管在**独立于 API 的域名**,API 节点被攻破不影响前端代码。
- 可由独立托管方校验脚本 SRI 与构建哈希;前端托管方被攻破的风险仍然存在。
嵌入式部署的取舍:
- `WebDrop.Frontend` 将 Vite dist 嵌入后端程序集,让 API 以同源方式提供页面。
- 这只简化部署,不增强前端与后端的隔离。若部署主机或镜像被控制,攻击者仍可替换页面并窃取用户后续输入的密钥。
后续:发布签名加 PWA 校验、CLI 客户端(协议不依赖浏览器,第三方可自行实现)。
@@ -78,6 +88,8 @@
- **不提供不可否认性**:帧没有逐发送者签名,所有成员共享同一密钥。
- **不防已被攻破的终端**。
- **不做口令叠加、后量子**,留待后续版本。
- **账号密码不是频道访问控制**。账号保险库只同步持有的资源链接;拿到频道链接的人仍按频道自身能力访问。
- **无密码重置**。忘记密码会失去保险库解密能力;未来若需要找回,须增加独立恢复密钥,而不能由服务端重置后解密旧保险库。
## 7. 服务端内存与日志的要求
@@ -85,3 +97,10 @@
- 令牌只做哈希比对,使用常量时间比较,不写日志。
- 日志不记录请求头、请求体、完整 URL;频道 id 在日志中截断或哈希。
- 错误信息不回显令牌和任何用户提交的字节。
## 8. 账号设备与本机缓存
同一账号的各设备拥有独立 sessions 和频道 S。撤销设备会删除该设备 sessions 与频道席位;不会移除账号成员或其他设备。撤销不能擦除已下载数据、已持有的频道 K 或被复制的账号私钥。当前未实现密钥轮换,不声称前向保密。
本机记住登录允许本浏览器使用者访问账号,退出时删除自动恢复凭据。OPFS 存储解密后临时文件,正常退出账号时清理;浏览器崩溃可能留下本机缓存,它不属于服务端密文。
+72 -76
View File
@@ -74,54 +74,17 @@ blur 的性能约束:
## 6. 布局与页面
WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,不按「谁发的」;发送设备只作为一行小字。
WebDrop 是**暂存区**,频道工作区:聊天按 thread 展示,文件按 thread/文件夹组织;发送设备作为来源信息。
### 6.1 手机(< 900px):最新 + 分类 + 底部动作栏
### 6.1 手机:频道导航与聊天/文件切换
```
┌────────────────────────────┐
│▒ abc123 🔒加密 ●●○ 2/3 🔍 ◐ ⇧│ ← 模糊顶栏
├────────────────────────────┤
│ 最新 │ 最近一项放大,拇指区一键取走
│ [图标] 屏幕录制.webm │
│ iPhone · 14:20 · 1.8 MB │
│ [ 预览 ] [ 下载 ] │ 文本则是 [ 复制 ]
├────────────────────────────┤
│ ▸ 便签 待办… │ 折叠,点开编辑
├────────────────────────────┤
│ [全部][文件][文本][图片]… │ 类型分组常驻(可横向滑动)
│ [缩略] project-assets.zip ⬇│
│ [缩略] 设计稿.svg v3 有更新 ⬇│
│ [文本] 明天 10 点开会… ⧉ │
├────────────────────────────┤
│▒ 粘贴 文件 相册 拍照 扫码 写 │ ← 模糊底部动作栏
└────────────────────────────┘
```
顶栏下显示账号频道导航与当前频道的 threads。新建频道自动创建“常规”thread,成员可直接聊天和上传。下方用“聊天 / 文件”切换同一 thread 的内容,聊天输入位于底部;文件区与桌面共用目录路径、上传、文件夹、排序、移动和预览组件。
- 底部是**动作**而不是输入框:粘贴(读剪贴板,文字或图片)、文件、相册、拍照、扫码、写。点「写」才弹出输入。
- 点列表项或「最新」卡打开底部抽屉详情(预览、操作、历史)。
### 6.2 电脑:频道与 threads | 聊天 | 线程文件
### 6.2 电脑(≥ 900px):列表 + 内联预览 + 窄栏
左侧固定频道导航与 thread 列表,可直接切换已加入频道、新建频道、新建/重命名 thread,并打开账号与设备设置。中间完整高度展示当前 thread 的聊天,底部输入支持文字和文件附件。右侧是资源管理器式文件区:路径面包屑、上传文件、新建文件夹、按名称/大小/时间排序、多选下载、移动、双击预览。
```
┌───────────────────────────────────────────────────┬───┐
│ abc123 🔒加密 ●●○ 2/3 🔍 ◐ │ ‹ │
├────────────────────┬──────────────────────────────┤2/3│
│ [粘贴][文件][扫码][写]│ 屏幕录制.webm │ ⇧ │
│ ▸ 便签 待办… │ iPhone · 14:20 · 剩 8 次下载 │ │
│ [全部 文件 文本 …] │ [下载] [删除] │ │
│ ☐ [缩略] zip ⬇ │ ┌────────────────────────┐ │ │
│ ☐ [缩略] 设计稿 ⬇ │ │ 内联预览(视频/图片/目录) │ │ │
│ ☐ [文本] 开会… ⧉ │ └────────────────────────┘ │ │
│ 已选 2 项 [下载] │ 修改历史 v3 当前 / v2 / v1 │ │
└────────────────────┴──────────────────────────────┴───┘
全局:Ctrl+V 粘贴即发送;拖到窗口任意位置即上传
```
- 点左侧一行,右侧内联预览,不弹窗来回开关;默认选中最新一项。
- 多选后可批量下载。
- 设备与分享是低频操作,默认收成右侧窄栏(在线数、分享图标),需要时展开。
- 窗口任意位置 `Ctrl+V` 都能直接发送(在输入框内粘贴时不拦截);拖到窗口任意位置即上传。
设备与分享收进顶栏弹层,不占第四列。预览在独立弹层打开,不挤占聊天。加密目录先同步文件名、大小与 thread/folder 归属;受限文件必须显式点击“解密并预览文件”或下载后才读取内容,不能因选择列表项或刷新消耗次数。
### 6.3 顶栏
@@ -138,6 +101,8 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
首页把「临时分享」与「设备频道」作为两个并列入口:前者用于发一段临时文本或文件,后者用于多设备长期同步。
主页右上角提供外观菜单,可跟随系统或切换亮色/暗色。右下角分别显示前端和后端的 tag 与短 commit hash,并显示换算为上海时间的构建时间(`yyyy-MM-dd HH:mm`);前端还显示包版本。无法取得 tag 时如实显示「未标记」。
```
┌──────────────────────────────────┐
│ WebDrop │
@@ -147,6 +112,10 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
│ │ 拖入文件 / 文件名列表 │ │
│ │ [选择文件] [粘贴] [创建临时分享]│ │
│ └────────────────────────────┘ │
│ ┌ 账号与同步 ─────────────────┐ │
│ │ 用户名 / 密码 [登录并同步] │ │
│ │ 资源清单先在本机加密后同步 │ │
│ └────────────────────────────┘ │
│ 我的频道(有才显示) │
│ 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。
```
草稿(创建者) 已封存(创建者与访客)
┌────────────────────────────────┐ ┌──────────────────────┐
│ 新建分享 · 草稿 [粘贴][文件][写]│ │ 我的分享 · 已封存只读 │
├───────────────┬────────────────┤ │ 分享链接 [复制][二维码]│
│ 内容清单 │ 选中的内容 │ │ 3 项内容 [全部下载] │
│ ▢ a.zip 46MB ✕│ [文件预览] │ │ 〃 开会地址… │
│ 〃 文本 120B ✕ │ [替换文件] │ │ ▢ a.zip [下载] │
│ │ │ └──────────────────────┘
│ │ 文本可直接编辑 │
├───────────────┴────────────────┤
│ 有效期 · 最多访客 · 下载次数 │
│ □ 我已了解,完成后无法再修改 │
│ [ 完成分享 ] │
└────────────────────────────────┘
```
### 6.5 频道 thread、文件管理与聊天
| 阶段 | 说明 |
|---|---|
| 草稿 | 左侧列出内容,选择后在右侧预览;创建者还握着所有者密钥,可直接修改文本、替换或移除文件,并调整有效期、最多访客、每个文件下载次数。**此时没有分享链接**,不会被别人看到 |
| 完成分享 | 页面内勾选「完成后会生成分享链接和二维码。此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期」后,按钮才可用;点击即生成链接与二维码 |
| 已封存(创建者) | 只读列表 + 「分享给别人」卡片(链接、二维码、说明) |
| 已封存(访客) | 只读列表,只能预览、下载、复制;「全部下载」按钮 |
频道内桌面布局为左侧频道/thread 导航、中央聊天、右侧线程文件管理器。新建频道自动创建“常规”thread;用户可以继续创建和命名其他 thread,在各自 thread 中发言或上传文件。聊天消息显示发送设备名称和时间。
- 访客打开链接后先看加入预览,确认才占席位。访客那一端如何识别「分享」:`members_can_upload=false` 即渲染为只读收件页,不需要新协议字段。
- 创建者的所有者链接不展示、不出二维码。
- 首页分成「我的分享」(草稿 / 已封存 / 收到的,带剩余时间)和「我的频道」。
左栏按 thread 显示虚拟文件夹与文件。可以创建/重命名 thread、创建/重命名文件夹、多选文件并移动到当前 thread 的文件夹。文件预览仍使用中央预览区;消息不混入文件列表。手机上 thread 和文件树可横向/纵向收缩,聊天输入固定在文件列表下方、上传动作栏上方。
thread 名、文件夹名和移动操作使用同一套 CONTROL 日志条目同步;端到端加密开启时元数据是密文,关闭时服务端可见。两种传输选项共享相同的线程和目录模型。GATED 文件通过独立加密目录显示名称和归属,内容仅在显式操作后读取;没有目录记录的旧文件仍显示通用占位。
其他弹层:
@@ -211,7 +165,8 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
| 条目详情 | 手机为底部抽屉,电脑为内联预览区;内容一致 |
| 扫码加入 | 相机识别二维码(浏览器自带 `BarcodeDetector`,不支持或无权限时退回「粘贴链接」)→ 加入预览(模式、权限)→ 加入后进入该频道 |
| 写文本 | 抽屉,`Ctrl+Enter` 发送;首页上用于快速分享 |
| 频道设置 | 尚未实现(席位管理等) |
| 频道设置 | 顶栏齿轮打开设置面板;所有者可修改名称、席位、成员上传、保留时间和受限文件下载次数 |
| 设备管理 | 设备面板显示已加入席位数;设备可重命名,所有者可重命名任意设备并移除其他设备 |
扫到的链接含密钥,只放在内存里,不写日志、不持久化。
@@ -292,7 +247,14 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
| 历史可读 | 加入说明处写明「持有链接的人可读取此频道全部历史」 |
| 元数据 | 创建与设置页注明「不隐藏 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+V` 直接发送;手机点「粘贴」读取剪贴板(无权限时退回「写」)。文件或图片直接入队,先出现「传输中」条目 |
| 拖放 | 桌面整页接收拖放;拖入时出现覆盖提示,不改变布局 |
| 文件上传 | 上传前可设置是否让单帧文件直接同步预览,以及受限文件的下载次数上限 |
| 频道设置 | 顶栏齿轮打开;设置通过现有 `PATCH /channels/{id}/settings` 更新,服务端广播变更给在线成员 |
| 复制 | 点击复制,按钮短暂变为「已复制」,不弹 Toast |
| 删除 | 单击删除后条目消失并给 5 秒「撤销」(撤销只在本地延迟提交删除) |
| 删除 | 确认删除后提交删除,并同步频道墓碑 |
| 新条目到达 | 若用户已滚到底部则自动跟随,否则底部出现「N 条新内容」 |
| 触控 | 可点击区域 ≥ 44px;底栏避开安全区(`env(safe-area-inset-*)`) |
@@ -362,5 +326,37 @@ WebDrop 是**暂存区**,不是聊天室:内容按类型与待办动作组织,
| 相机 | 「拍照」用 `<input capture="environment">`,iOS 与 Android 都会直接拉起相机;扫码在 Android Chrome 用 `BarcodeDetector`,iOS Safari 不支持,提示用系统相机扫码后打开链接,或粘贴链接。相机、剪贴板读取都要求 https(`localhost` 除外) |
| 剪贴板 | iOS 读取时会弹出系统「粘贴」确认,Android 会要求权限;被拒绝时退回「写」 |
| 图片格式 | 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 |
| `fromFrame` | 上传断点续传:从第 k 帧开始产出(字节与完整加密完全相同)。仅支持 `Blob` / `Uint8Array` |
| `ts`, `dev` | 覆盖时间戳和设备名 |
| `label` | 仅 raw 模式:`X-WD-Label` |
**GATED 的判断**:来源长度已知时创建计划时就确定;来源是流时,要产出第 0 帧之后 `plan.gated` 和 `frame0Headers` 才确定,请在拿到第 0 帧后再读取。
+1
View File
@@ -13,6 +13,7 @@ export const Flags = {
Snapshot: 2,
Gated: 4,
Enc: 8,
Control: 16,
} as const;
/** 档位 padding 的明文长度(protocol §6)。 */
+2 -2
View File
@@ -49,7 +49,7 @@ export interface Envelope {
export function parseEnvelope(data: Uint8Array): Envelope {
if (data.length < HEADER_SIZE || data[0] !== 1) throw new IntegrityError('bad_envelope');
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);
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> {
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 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';
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';
async function hkdfBytes(ikm: Uint8Array, salt: Uint8Array, label: string): Promise<Bytes> {
+47 -44
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 { IntegrityError, LimitError } from './errors.js';
@@ -14,6 +14,10 @@ export interface ItemMeta {
ts: number;
/** 设备名。 */
dev?: string;
/** WebDrop 频道中的 thread ID,加密元数据。 */
threadId?: string;
/** 文件所在的虚拟文件夹 ID,加密元数据。 */
folderId?: string;
}
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;
o.ts = meta.ts;
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));
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');
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 (typeof o.size !== 'number' || !Number.isSafeInteger(o.size) || o.size < 0) throw new IntegrityError('bad_meta');
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 interface PlanOptions {
control?: boolean;
/** 指定条目 id(重试、续传时必须复用同一个);默认随机生成。 */
itemId?: Uint8Array;
/** 作为频道的「最新快照」。快照必须是单帧,所以内容不能超过一帧。 */
@@ -109,7 +122,7 @@ export interface ItemPlan {
* 要等第 0 帧产出之后才确定,所以请在拿到第 0 帧之后再读取它和 frame0Headers。
*/
readonly gated: boolean;
/** 第 0 帧要带的附加请求头(X-WD-Max-Downloads / X-WD-Label)。 */
/** 第 0 帧要带的附加请求头(X-WD-Max-Downloads)。 */
frame0Headers: Record<string, string>;
/** 按序产出加密后的帧。同一个计划只能迭代一次。 */
frames: AsyncGenerator<EncodedFrame, void, void>;
@@ -134,7 +147,22 @@ async function* chunksOf(source: ContentSource): AsyncGenerator<Uint8Array, void
return;
}
if (typeof Blob !== 'undefined' && source instanceof Blob) {
yield* chunksOf(source.stream());
if (typeof source.stream === 'function') {
yield* chunksOf(source.stream());
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) {
@@ -153,6 +181,18 @@ async function* chunksOf(source: ContentSource): AsyncGenerator<Uint8Array, void
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 {
data: Bytes;
last: boolean;
@@ -229,11 +269,12 @@ function resolveGating(opts: PlanOptions, fitsInOneFrame: boolean | undefined):
* enc 模式:把内容加密成一个条目(protocol §7)。
* 小内容单帧、非 GATED;放不下就拆成多帧并标记 GATED。第 0 帧携带元数据。
*/
export function planEncItem(
export function planItem(
seal: (p: { flags: number; itemId: Uint8Array; index: number; data: Uint8Array }) => Promise<Bytes>,
source: ContentSource,
meta: ItemMeta,
opts: PlanOptions = {},
encrypted = true,
): ItemPlan {
const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId();
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);
const snapshot = opts.snapshot === true;
if (opts.control && (snapshot || gated)) throw new RangeError('控制条目必须是非快照的单帧');
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> => ({
index,
final: last,
@@ -299,42 +341,3 @@ export function planEncItem(
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>;
}
export type ReceivedKind = ItemKind | 'raw';
export type ReceivedKind = ItemKind;
export interface ReceivedItem {
itemId: string;
@@ -124,7 +124,7 @@ export class ItemReader {
if (isEncFrame !== (this.#ctx.mode === 'enc')) throw new IntegrityError('wrong_mode');
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 isFinal = (env.flags & Flags.Final) !== 0;
// 非 GATED 条目必须是单帧且 FINAL(协议规则,服务端也强制)
@@ -156,7 +156,7 @@ export class ItemReader {
let meta = state?.meta;
let content = data;
if (this.#ctx.mode === 'enc' && env.frameIndex === 0) {
if (env.frameIndex === 0) {
const first = decodeFirstFrame(data);
meta = first.meta;
content = first.content;
@@ -179,7 +179,7 @@ export class ItemReader {
const item: ReceivedItem = {
itemId,
seq,
kind: this.#ctx.mode === 'raw' ? 'raw' : (state.meta?.kind ?? 'other'),
kind: state.meta?.kind ?? 'other',
snapshot: (itemFlags & Flags.Snapshot) !== 0,
gated,
complete,
+21 -16
View File
@@ -4,7 +4,7 @@ import { CapabilityError, IntegrityError, LimitError, LinkError, ModeMismatchErr
import { encodeEnvelope, sealFrame } from './frame.js';
import { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256, type ChannelKeys } from './kdf.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 { decryptItemStream, type DecryptStreamOptions, type ItemDecodeResult } from './stream.js';
@@ -12,6 +12,9 @@ export type AuthRole = 'read' | 'write' | 'owner';
/** 频道设置,字段名与 API 一致(snake_case)。 */
export interface ChannelSettings {
join_policy?: 'open' | 'invite';
allow_guests?: boolean;
name?: string;
seats?: number;
members_can_upload?: boolean;
delete_policy?: 'owner_only' | 'own' | 'anyone';
@@ -56,8 +59,10 @@ export interface PlanItemOptions extends PlanOptions {
ts?: number;
/** 覆盖设备名。默认使用 session.deviceName。 */
dev?: string;
/** 仅 raw 模式:条目标签,作为 X-WD-Label 随第 0 帧发送(明文,服务端可见)。enc 模式下忽略,名字放在加密的元数据里。 */
label?: string;
/** Workspace thread ID, encoded only in encrypted item metadata. */
threadId?: string;
/** Virtual folder ID for files, encoded only in encrypted item metadata. */
folderId?: string;
}
export interface FileMetaOverrides {
@@ -255,22 +260,17 @@ export class ChannelSession {
#meta(kind: ItemMeta['kind'], opts: PlanItemOptions, extra: Partial<ItemMeta> = {}): ItemMeta {
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;
if (dev !== undefined) meta.dev = dev;
return meta;
}
#plan(source: ContentSource, meta: ItemMeta, opts: PlanItemOptions): ItemPlan {
if (this.mode === 'enc') {
return planEncItem(
async (p) => sealFrame(await this.itemKey(p.itemId), this.idBytes, p),
source,
meta,
opts,
);
}
// raw:没有元数据,内容原样放进负载
return planRawItem((p) => encodeEnvelope(p.flags, p.itemId, p.index, p.payload), source, opts);
return planItem(async (p) => this.mode === 'enc'
? sealFrame(await this.itemKey(p.itemId), this.idBytes, p)
: encodeEnvelope(p.flags, p.itemId, p.index, p.data), source, meta, opts, this.mode === 'enc');
}
encryptBytes(content: Uint8Array, meta: Partial<ItemMeta> & { kind?: ItemMeta['kind'] } = {}, opts: PlanItemOptions = {}): ItemPlan {
@@ -331,11 +331,16 @@ export class ChannelSession {
// ───────────────────────── 设备名(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。
* @param opts.endpointHash 指定目标席位;所有者可给其他终端重命名。
* @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);
if (this.mode === 'raw') {
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 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,
plain,
);
+9 -8
View File
@@ -57,13 +57,15 @@ class ByteQueue {
async function* bytesOf(source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array, void, void> {
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
const reader = source.getReader();
let finished = false;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) return;
if (done) { finished = true; return; }
yield value;
}
} finally {
if (!finished) await reader.cancel().catch(() => {});
reader.releaseLock();
}
}
@@ -136,21 +138,19 @@ export async function* decryptItemFrames(
if (finalSeen) throw new IntegrityError('frame_after_final');
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;
else if (flagsMask !== itemFlags) throw new IntegrityError('flags_changed');
let data: Bytes;
if (ctx.mode === 'enc') {
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 {
data = env.payload;
}
if (env.frameIndex === 0) {
const first = decodeFirstFrame(data); meta = first.meta; data = first.content;
}
expected++;
if ((env.flags & Flags.Final) !== 0) finalSeen = true;
@@ -190,7 +190,8 @@ export function decryptItemStream(
controller.error(e);
}
},
async cancel() {
async cancel(reason) {
reject(reason ?? new DOMException('解密已取消', 'AbortError'));
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 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, []]
: existsSync(linuxExe)
? [linuxExe, []]
@@ -41,6 +43,10 @@ const command: [string, string[]] | undefined = existsSync(exe)
let proc: ChildProcess | undefined;
let dataDir = '';
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 () => {
if (!command) return;
@@ -69,6 +75,16 @@ beforeAll(async () => {
});
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);
afterAll(async () => {
@@ -85,7 +101,7 @@ async function createChannel(opts: Parameters<typeof ChannelSession.create>[0] =
const created = await ChannelSession.create(opts);
const res = await fetch(api('/channels'), {
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),
});
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') {
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);
}
@@ -105,7 +121,7 @@ async function send(session: ChannelSession, plan: ItemPlan, role: AuthRole) {
for await (const frame of plan.frames) {
const res = await fetch(api(`/channels/${session.id}/frames`), {
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,
});
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) {
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);
return parseLogRecords(new Uint8Array(await res.arrayBuffer()));
}
/** 连接 WebSocket(凭据放在子协议里),收集事件直到满足 until。 */
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']!}`];
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);
ws.binaryType = 'arraybuffer';
@@ -227,7 +244,7 @@ suite('端到端(真实服务端)', () => {
const frame = (await collectFrames(again))[0]!;
const res = await fetch(api(`/channels/${owner.id}/frames`), {
method: 'POST',
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' },
headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' },
body: frame.bytes,
});
expect(res.status).toBe(200);
@@ -239,7 +256,7 @@ suite('端到端(真实服务端)', () => {
const marker = 'UNIQUE-PLAINTEXT-MARKER-7f3a9c';
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());
expect(new TextDecoder('latin1').decode(wire)).not.toContain(marker);
expect(new TextDecoder('latin1').decode(wire)).not.toContain('"kind"');
@@ -260,7 +277,7 @@ suite('端到端(真实服务端)', () => {
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);
const { content: stream, result } = member.decryptItemStream(res.body!, { itemId: plan.idBytes });
const out = await readAll(stream);
@@ -278,15 +295,15 @@ suite('端到端(真实服务端)', () => {
await send(owner, plan, 'owner');
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!);
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(((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);
await resumed.arrayBuffer();
}, 60_000);
@@ -308,7 +325,7 @@ suite('端到端(真实服务端)', () => {
if (sent === 2) break;
const res = await fetch(api(`/channels/${owner.id}/frames`), {
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,
});
expect(res.status).toBe(201);
@@ -322,14 +339,14 @@ suite('端到端(真实服务端)', () => {
for await (const f of rest.frames) {
const res = await fetch(api(`/channels/${owner.id}/frames`), {
method: 'POST',
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' },
headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' },
body: f.bytes,
});
statuses.push(res.status);
}
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!);
expect(Buffer.compare(await readAll(stream), content)).toBe(0);
expect((await result).complete).toBe(true);
@@ -343,7 +360,7 @@ suite('端到端(真实服务端)', () => {
const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`);
// 第一次:只读前约 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 chunks: Uint8Array[] = [];
let got = 0;
@@ -361,7 +378,7 @@ suite('端到端(真实服务端)', () => {
})();
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);
const dec2 = owner.decryptItemStream(second.body!, { fromIndex: part1.result.nextIndex });
const part2 = await readAll(dec2.content);
@@ -378,12 +395,12 @@ suite('端到端(真实服务端)', () => {
const blob = await member.sealLabel('我的 iPhone');
const put = await fetch(api(`/channels/${owner.id}/seats/me`), {
method: 'PUT',
headers: { ...member.authHeaders('read'), 'Content-Type': 'application/octet-stream' },
headers: { ...headersFor(member, 'read'), 'Content-Type': 'application/octet-stream' },
body: blob,
});
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 mine = seats.find((s) => s.endpoint_hash === toBase64Url(member.endpointHash))!;
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 put = await fetch(api(`/channels/${owner.id}/seats/me`), {
method: 'PUT',
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' },
headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' },
body: blob,
});
expect(put.status).toBe(204);
@@ -415,11 +432,11 @@ suite('端到端(真实服务端)', () => {
for (const r of records) out.push(...(await reader.push(r)));
return out;
})());
expect(item!.kind).toBe('raw');
expect(item!.kind).toBe('text');
expect(itemText(item!)).toBe('raw 明文 visible');
// 对比 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');
});
@@ -428,8 +445,8 @@ suite('端到端(真实服务端)', () => {
const member = await ChannelSession.open(owner.link(ORIGIN));
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(() => member.authHeaders('owner')).toThrow();
expect(() => headersFor(member, 'owner')).toThrow();
});
});
+12 -10
View File
@@ -315,29 +315,30 @@ describe('设备名', () => {
});
describe('raw 模式往返', () => {
it('单帧:负载就是内容,没有加密标志', async () => {
it('单帧:保留统一元数据,没有加密标志', async () => {
const { session: owner } = await ChannelSession.create({ mode: 'raw' });
const member = await ChannelSession.open(owner.link(ORIGIN));
const [frame] = await collectFrames(owner.encryptText('明文内容'));
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));
expect(item.kind).toBe('raw');
expect(item.meta).toBeUndefined();
expect(item.kind).toBe('text');
expect(item.meta?.kind).toBe('text');
expect(itemText(item)).toBe('明文内容');
});
it('大内容拆成 GATED 多帧', async () => {
const { session } = await ChannelSession.create({ mode: 'raw' });
const content = pseudoRandom(100, 5);
const plan = session.encryptBytes(content, {}, { maxFrameData: 40 });
const plan = session.encryptBytes(content, {}, { maxFrameData: 80 });
const frames = await collectFrames(plan);
expect(plan.gated).toBe(true);
expect(frames.map((f) => f.bytes.length - 26)).toEqual([40, 40, 20]);
expect(frames.map((f) => f.final)).toEqual([false, false, true]);
expect(frames.length).toBeGreaterThan(1);
expect(frames.every((f) => f.bytes.length - 26 <= 80)).toBe(true);
expect(frames.at(-1)?.final).toBe(true);
const reader = session.createItemReader();
let last: ReceivedItem | undefined;
@@ -346,10 +347,11 @@ describe('raw 模式往返', () => {
expect(toHex(last!.content)).toBe(toHex(content));
});
it('label 放进第 0 帧的附加请求头', async () => {
it('名称使用与加密条目一致的元数据', async () => {
const { session } = await ChannelSession.create({ mode: 'raw' });
const plan = session.encryptBytes(new Uint8Array(3), {}, { label: 'note.txt' });
expect(plan.frame0Headers['X-WD-Label']).toBe('note.txt');
const plan = session.encryptBytes(new Uint8Array(3), { name: '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),
ts: m.ts,
...(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,
control: item.control,
gated: item.force_gated ? true : 'auto',
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
+53
View File
@@ -0,0 +1,53 @@
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
COPY server/Directory.Build.props ./server/
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.Frontend/WebDrop.Frontend.csproj ./server/src/WebDrop.Frontend/
COPY server/src/WebDrop.Storage/WebDrop.Storage.csproj ./server/src/WebDrop.Storage/
RUN dotnet restore server/src/WebDrop.Api/WebDrop.Api.csproj \
--runtime linux-x64
COPY server/ ./server/
COPY --from=frontend /src/web/dist/ ./web/dist/
RUN dotnet publish server/src/WebDrop.Api/WebDrop.Api.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained true \
--no-restore \
--output /out
RUN find /root/.nuget/packages/sqlitepclraw.lib.e_sqlite3 -path '*/runtimes/linux-x64/native/libe_sqlite3.so' \
-exec cp {} /out/libe_sqlite3.so \; \
&& test -f /out/libe_sqlite3.so
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0 AS runtime
WORKDIR /app
COPY --from=build /out/webdrop /app/webdrop
COPY --from=build /out/libe_sqlite3.so /app/libe_sqlite3.so
RUN mkdir -p /data \
&& chown 10001:10001 /data \
&& chmod 0750 /data
ENV ASPNETCORE_URLS=http://+:5000 \
WebDrop__DataDir=/data \
DOTNET_EnableDiagnostics=0
USER 10001:10001
EXPOSE 5000
ENTRYPOINT ["/app/webdrop"]
-28
View File
@@ -1,28 +0,0 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
RUN apt-get update \
&& apt-get install -y --no-install-recommends clang zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
COPY server/ ./server/
RUN dotnet publish server/src/WebDrop.Api/WebDrop.Api.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained true \
--output /out
RUN find /root/.nuget/packages/sqlitepclraw.lib.e_sqlite3 -path '*/runtimes/linux-x64/native/libe_sqlite3.so' \
-exec cp {} /out/libe_sqlite3.so \; \
&& test -f /out/libe_sqlite3.so
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0 AS runtime
WORKDIR /app
COPY --from=build /out/webdrop /app/webdrop
COPY --from=build /out/libe_sqlite3.so /app/libe_sqlite3.so
RUN mkdir -p /data \
&& chown 10001:10001 /data \
&& chmod 0750 /data
ENV ASPNETCORE_URLS=http://+:5000 \
WebDrop__DataDir=/data \
DOTNET_EnableDiagnostics=0
USER 10001:10001
EXPOSE 5000
ENTRYPOINT ["/app/webdrop"]
+3
View File
@@ -8,6 +8,7 @@
|---|---|
| `src/WebDrop.Core` | 领域类型、规则和业务逻辑(`ChannelService`、`ShareService`),不做 IO,只依赖抽象 |
| `src/WebDrop.Storage` | SQLite 存储(`SqliteChannelStore`)和磁盘 blob(`DiskBlobStore`) |
| `src/WebDrop.Frontend` | 可选嵌入 Vite dist,提供静态文件、SPA fallback 与页面 CSP;不依赖业务层 |
| `src/WebDrop.Api` | Minimal API、WebSocket / SSE / 长轮询、订阅枢纽、后台清理、限流、安全头 |
| `tests/` | `Core.Tests`(单元与服务层,真实 SQLite)、`Api.Tests`(真实 Kestrel 上的集成测试) |
@@ -16,6 +17,7 @@
```powershell
dotnet test # 全部测试
dotnet run --project src/WebDrop.Api # 开发运行,默认 http://localhost:5000
npm --prefix ../web run build # 先构建前端时,dotnet publish 会自动嵌入 web/dist
# NativeAOT 发布(Windows 需要 vswhere.exe 在 PATH 里)
$env:PATH += ";${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer"
@@ -47,6 +49,7 @@ python3 scripts/smoke.py out/linux-x64/webdrop # 跨平台版本,只用
- 单实例部署:订阅枢纽在进程内,多实例需要换成外部发布订阅。
- 日志不记录请求头、请求体、完整 URL;Hosting 诊断日志已关闭,避免频道 id 进入日志。
- NativeAOT:不用 SignalR、EF Core、MVC、反射式 JSON;所有 AOT / 裁剪警告视为错误。
- `WebDrop.Frontend` 只负责嵌入静态资源与 SPA fallback。`Containerfile` 自动构建 `web/`;单独发布 API 时,只有 `web/dist` 存在或指定 `WebDropFrontendDist` 后才包含前端。
## 验证码(Cap)
+1
View File
@@ -2,6 +2,7 @@
<Folder Name="/src/">
<Project Path="src/WebDrop.Api/WebDrop.Api.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" />
</Folder>
<Folder Name="/tests/">
+1 -1
View File
@@ -3,5 +3,5 @@ namespace WebDrop.Api;
public static class ApiContract
{
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
public const string Version = "1.0.0";
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; }
}
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(GoneLogBody))]
[JsonSerializable(typeof(ServerInfoBody))]
@@ -62,5 +136,21 @@ public sealed class LimitsBody
[JsonSerializable(typeof(AppendResult))]
[JsonSerializable(typeof(SiteverifyRequest))]
[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)]
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.Serialization.Metadata;
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static class Endpoints
public static partial class Endpoints
{
public const string CreatePolicy = "create";
private const string LastSeqHeader = "X-WD-Last-Seq";
private const string OctetStream = "application/octet-stream";
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");
@@ -37,15 +38,18 @@ public static class Endpoints
},
}, ApiJson.Default.ServerInfoBody));
MapAccounts(api, accounts);
// ───────── 频道 ─────────
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 endpoint = RequestAuth.ParseEndpointHash(ctx.Request.Headers[RequestAuth.EndpointHeader]);
string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString();
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);
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) =>
Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true)), ApiJson.Default.SeatJoined));
api.MapPost("/channels/{id}/seats", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
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) =>
{
@@ -83,6 +92,14 @@ public static class Endpoints
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) =>
{
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 creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
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);
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);
});
@@ -131,7 +147,7 @@ public static class Endpoints
throw new WebDropException(ErrorCode.BadRequest);
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);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted);
@@ -143,7 +159,7 @@ public static class Endpoints
ws = await ctx.WebSockets.AcceptWebSocketAsync("wd.v1");
_ = WatchClose(ws, cts);
return new WebSocketSink(ws);
}, cts.Token);
}, cts.Token, () => StreamAccountValid(ctx, accounts, creds));
}
finally
{
@@ -166,7 +182,7 @@ public static class Endpoints
ctx.Response.Headers["X-Accel-Buffering"] = "no";
await ctx.Response.StartAsync();
return new SseSink(ctx.Response);
}, ctx.RequestAborted);
}, ctx.RequestAborted, () => StreamAccountValid(ctx, accounts, creds));
return Results.Empty;
});
@@ -211,11 +227,12 @@ public static class Endpoints
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);
string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString();
await captcha.VerifyAsync(token.Length > 0 ? token : null, ct);
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);
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) =>
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) =>
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>
public static async Task RunAsync(
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);
ILogSink? sink = null;
@@ -36,6 +36,7 @@ public static class LogStreamer
{
while (!ct.IsCancellationRequested)
{
if (validateAccount is not null && !validateAccount()) throw new WebDropException(ErrorCode.AccountRequired);
var changed = sub.Changed; // 先取任务再读状态:读取期间的发布不会丢
var page = await svc.ReadLogAsync(id, creds, cursor, PageSize, ct).ConfigureAwait(false);
sink ??= await open().ConfigureAwait(false);
+28 -4
View File
@@ -1,4 +1,5 @@
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
@@ -23,28 +24,51 @@ public static class RequestAuth
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>
/// 浏览器的 WebSocket 不能设请求头,凭据放在子协议里:
/// wd.v1, wd.role.&lt;role&gt;, wd.auth.&lt;b64url&gt;, wd.ep.&lt;b64url&gt;
/// </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)
{
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.ep.", StringComparison.Ordinal)) endpoint = p["wd.ep.".Length..];
else if (p.StartsWith("wd.account.", StringComparison.Ordinal)) accountToken = p["wd.account.".Length..];
}
byte[]? token = null;
if (auth is not null && !Codec.TryDecodeExact(auth, TokenHash.Size, out token))
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);
@@ -11,6 +11,7 @@
<ItemGroup>
<ProjectReference Include="..\WebDrop.Core\WebDrop.Core.csproj" />
<ProjectReference Include="..\WebDrop.Frontend\WebDrop.Frontend.csproj" />
<ProjectReference Include="..\WebDrop.Storage\WebDrop.Storage.csproj" />
</ItemGroup>
+9 -5
View File
@@ -1,5 +1,6 @@
using System.Threading.RateLimiting;
using WebDrop.Core;
using WebDrop.Frontend;
using WebDrop.Storage;
namespace WebDrop.Api;
@@ -23,8 +24,9 @@ public static class WebDropApp
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(options.Limits);
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<IBlobStore>(_ => new DiskBlobStore(Path.Combine(options.DataDir, "blobs")));
builder.Services.AddSingleton<SqliteChannelStore>(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "webdrop.db")));
builder.Services.AddSingleton<IBlobStore>(_ => new DiskBlobStore(Path.Combine(options.DataDir, "v2", "blobs")));
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<ICaptchaVerifier>(sp => CreateCaptchaVerifier(options.Captcha, sp));
builder.Services.AddSingleton<ChannelHub>();
@@ -41,7 +43,7 @@ public static class WebDropApp
.WithOrigins(options.CorsOrigins)
.AllowAnyMethod()
.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")
.SetPreflightMaxAge(TimeSpan.FromHours(1))));
}
@@ -63,6 +65,7 @@ public static class WebDropApp
});
var app = builder.Build();
_ = app.Services.GetRequiredService<SqliteChannelStore>();
if (options.TrustForwardedHeaders)
{
@@ -80,7 +83,8 @@ public static class WebDropApp
if (options.CorsOrigins.Length > 0) app.UseCors();
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat });
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;
}
@@ -114,7 +118,7 @@ public static class WebDropApp
QueueLimit = 0,
};
/// <summary>API 不提供任何页面。禁用缓存、禁止嗅探、不发送 Referer、不允许被嵌入。</summary>
/// <summary>API 默认禁止缓存、嗅探和嵌入;嵌入式前端的页面策略由静态模块单独设置。</summary>
private static Task SecurityHeaders(HttpContext ctx, RequestDelegate next)
{
var h = ctx.Response.Headers;
+21
View File
@@ -92,6 +92,26 @@ public interface IResourceTx
Channel? GetChannel(Id16 id);
void InsertChannel(Channel channel);
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 AddChannelBytes(Id16 id, long delta);
void SetCompactedThrough(Id16 id, long seq);
@@ -113,6 +133,7 @@ public interface IResourceTx
void UpdateItem(Item item);
void DeleteItemRows(Id16 channelId, Id16 itemId);
int CountItems(Id16 channelId);
int CountControlItems(Id16 channelId);
IReadOnlyList<Item> ListOldestItems(Id16 channelId, int limit);
Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId);
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
{
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,
};
}
+67 -17
View File
@@ -6,7 +6,7 @@ namespace WebDrop.Core;
/// 频道的全部业务逻辑。依赖抽象(存储、blob、时钟、事件),不含 HTTP、SQL、文件细节。
/// 约定:所有失败以 <see cref="WebDropException"/> 表达;提交成功后才发事件;blob 先写后提交。
/// </summary>
public sealed class ChannelService(
public sealed partial class ChannelService(
IResourceStore store,
IBlobStore blobs,
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 (req.Mode is not { } mode) throw new WebDropException(ErrorCode.BadRequest);
@@ -44,14 +46,17 @@ public sealed class ChannelService(
throw new WebDropException(ErrorCode.BadRequest);
long now = clock.NowMs;
var (passwordHash, passwordSalt) = HashJoinPassword(req.JoinPassword);
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 =>
{
if (tx.HasResourceId(id)) throw new WebDropException(ErrorCode.Conflict);
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 ToInfo(channel, 1);
@@ -77,6 +82,12 @@ public sealed class ChannelService(
? clock.NowMs + updated.TtlSeconds * 1000L
: channel.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));
});
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 (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
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);
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);
}
CheckAdmission(tx, channel, creds, password);
if (used >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull);
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);
});
if (isNew) events.SeatsChanged(id);
@@ -126,13 +146,13 @@ public sealed class ChannelService(
EndpointHash = Codec.Encode(s.EndpointHash),
JoinedAt = s.JoinedAt,
Label = s.Label is null ? null : Codec.Encode(s.Label),
AccountName = s.AccountName,
}).ToList();
});
public void SetSeatLabel(Id16 id, Credentials creds, byte[] label)
{
if (label.Length == 0) throw new WebDropException(ErrorCode.BadRequest);
if (label.Length > limits.MaxSeatLabelBytes) throw new WebDropException(ErrorCode.TooLarge);
ValidateSeatLabel(label);
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required);
@@ -142,6 +162,26 @@ public sealed class ChannelService(
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)
{
store.Write(tx =>
@@ -149,6 +189,7 @@ public sealed class ChannelService(
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
RequireOwner(principal);
if (!tx.DeleteSeat(id, endpointHash)) throw new WebDropException(ErrorCode.NotFound);
tx.DenyEndpoint(id, endpointHash);
return 0;
});
events.SeatsChanged(id);
@@ -158,12 +199,10 @@ public sealed class ChannelService(
public async Task<AppendResult> AppendFrameAsync(
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 (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);
byte[] digest = SHA256.HashData(body.Span);
@@ -189,7 +228,7 @@ public sealed class ChannelService(
AppendOutcome outcome;
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
{
@@ -212,7 +251,7 @@ public sealed class ChannelService(
private AppendOutcome AppendInTx(
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);
CheckWritable(channel, principal, env);
@@ -227,10 +266,11 @@ public sealed class ChannelService(
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);
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,
now, now + ttlMs, null, label);
now, env.IsControl ? long.MaxValue : now + ttlMs, null, principal.AccountName);
tx.InsertItem(item);
}
else
@@ -305,6 +345,7 @@ public sealed class ChannelService(
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 的单帧
if (!env.IsGated && (env.FrameIndex != 0 || !env.IsFinal)) 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);
var role = VerifyRole(channel, creds);
if (seat == SeatRule.Required && tx.GetSeat(id, creds.EndpointHash) is null)
throw new WebDropException(ErrorCode.NotJoined);
return (channel, new Principal(role, creds.EndpointHash));
var member = creds.AccountName is null ? null : tx.GetChannelMember(id, creds.AccountName);
if (role == Role.Owner && member?.Role != "owner") throw new WebDropException(ErrorCode.AccountRequired);
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)
@@ -573,6 +621,8 @@ public sealed class ChannelService(
private static ChannelInfo ToInfo(Channel channel, int seatsUsed) => new()
{
Id = channel.Id.ToBase64Url(),
CreatorAccount = channel.CreatorAccount,
PasswordRequired = channel.JoinPasswordHash is not null,
Mode = channel.Mode,
Settings = channel.Settings,
SeatsUsed = seatsUsed,
+6
View File
@@ -5,6 +5,8 @@ namespace WebDrop.Core;
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("mode")] public ChannelMode? Mode { get; init; }
[JsonPropertyName("settings")] public ChannelSettings? Settings { get; init; }
@@ -40,6 +42,7 @@ public sealed class ShareInfo
public sealed class ShareItemInfo
{
[JsonPropertyName("complete")] public bool Complete { get; init; }
[JsonPropertyName("item_id")] public required string ItemId { get; init; }
[JsonPropertyName("bytes")] public required long Bytes { get; init; }
[JsonPropertyName("frames")] public required int Frames { get; init; }
@@ -49,6 +52,8 @@ public sealed class ShareItemInfo
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("mode")] public required ChannelMode Mode { get; init; }
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
@@ -72,6 +77,7 @@ public sealed class SeatJoined
public sealed class SeatInfo
{
[JsonPropertyName("account_name")] public string? AccountName { get; init; }
[JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; }
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
[JsonPropertyName("label")] public string? Label { get; init; }
+1
View File
@@ -34,6 +34,7 @@ public enum FrameFlags : byte
Snapshot = 2,
Gated = 4,
Enc = 8,
Control = 16,
}
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;
byte flags = data[1];
if ((flags & 0xF0) != 0) return false;
if ((flags & 0xE0) != 0) return false;
uint payloadLen = BinaryPrimitives.ReadUInt32BigEndian(data[22..]);
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 IsSnapshot => (Flags & FrameFlags.Snapshot) != 0;
public bool IsEnc => (Flags & FrameFlags.Enc) != 0;
public bool IsControl => (Flags & FrameFlags.Control) != 0;
/// <summary>条目级标志:同一条目的所有帧必须一致。</summary>
public FrameFlags ItemFlags => Flags & (FrameFlags.Snapshot | FrameFlags.Gated);
public FrameFlags ItemFlags => Flags & (FrameFlags.Snapshot | FrameFlags.Gated | FrameFlags.Control);
}
public static class PayloadTiers
+10
View File
@@ -17,6 +17,10 @@ public enum ErrorCode
RateLimited,
VisitorsFull,
Sealed,
AccountRequired,
InvitationRequired,
ChannelPasswordRequired,
ChannelPasswordInvalid,
}
/// <summary>预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。</summary>
@@ -53,6 +57,10 @@ public static class ErrorCodes
ErrorCode.RateLimited => "rate_limited",
ErrorCode.VisitorsFull => "visitors_full",
ErrorCode.Sealed => "sealed",
ErrorCode.AccountRequired => "account_required",
ErrorCode.InvitationRequired => "invitation_required",
ErrorCode.ChannelPasswordRequired => "channel_password_required",
ErrorCode.ChannelPasswordInvalid => "channel_password_invalid",
_ => "bad_request",
};
@@ -70,6 +78,8 @@ public static class ErrorCodes
ErrorCode.CaptchaUnavailable => 503,
ErrorCode.RateLimited => 429,
ErrorCode.VisitorsFull => 403,
ErrorCode.AccountRequired => 401,
ErrorCode.InvitationRequired or ErrorCode.ChannelPasswordRequired or ErrorCode.ChannelPasswordInvalid => 403,
_ => 400,
};
}
+13 -6
View File
@@ -11,7 +11,10 @@ public sealed record Channel(
long ExpiresAt,
long LastSeq,
long CompactedThrough,
long Bytes);
long Bytes,
string CreatorAccount,
byte[]? JoinPasswordHash = null,
byte[]? JoinPasswordSalt = null);
public sealed record Share(
Id16 Id,
@@ -23,7 +26,8 @@ public sealed record Share(
long ExpiresAt,
long? SealedAt,
Id16? ManifestItemId,
long Bytes)
long Bytes,
string CreatorAccount)
{
public bool IsSealed => SealedAt is not null;
}
@@ -71,14 +75,17 @@ public sealed record Item(
long CreatedAt,
long ExpiresAt,
long? CloseDeadline,
string? Label)
string? AuthorAccount)
{
public bool IsGated => (Flags & FrameFlags.Gated) != 0;
public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0;
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>
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);
/// <summary>一次请求验证通过后的身份。</summary>
public sealed record Principal(Role Role, byte[] EndpointHash);
public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null);
/// <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 DownloadGraceSeconds { get; set; } = 300;
public int TombstoneRetentionSeconds { get; set; } = 604_800;
public int MaxLabelBytes { get; set; } = 255;
public int MaxSeatLabelBytes { get; set; } = 256;
}
public sealed class ChannelSettings
{
[JsonPropertyName("seats")] public int Seats { get; init; } = 2;
[JsonPropertyName("members_can_upload")] public bool MembersCanUpload { get; init; } = true;
[JsonPropertyName("delete_policy")] public DeletePolicy DeletePolicy { get; init; } = DeletePolicy.Own;
[JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; init; } = 86_400;
[JsonPropertyName("max_items")] public int MaxItems { get; init; } = 1000;
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; init; } = 10;
[JsonPropertyName("ui_restrict_copy_download")] public bool UiRestrictCopyDownload { get; init; }
[JsonPropertyName("join_policy")] public string JoinPolicy { get; set; } = "open";
[JsonPropertyName("allow_guests")] public bool AllowGuests { get; set; } = true;
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("seats")] public int Seats { get; set; } = 2;
[JsonPropertyName("members_can_upload")] public bool MembersCanUpload { get; set; } = true;
[JsonPropertyName("delete_policy")] public DeletePolicy DeletePolicy { get; set; } = DeletePolicy.Own;
[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)
{
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 (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds) 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
{
[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("members_can_upload")] public bool? MembersCanUpload { get; init; }
[JsonPropertyName("delete_policy")] public DeletePolicy? DeletePolicy { get; init; }
@@ -50,6 +58,9 @@ public sealed class ChannelSettingsPatch
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,
MembersCanUpload = MembersCanUpload ?? s.MembersCanUpload,
DeletePolicy = DeletePolicy ?? s.DeletePolicy,
@@ -62,9 +73,9 @@ public sealed class ChannelSettingsPatch
public sealed class ShareSettings
{
[JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; init; } = 86_400;
[JsonPropertyName("max_visitors")] public int MaxVisitors { get; init; }
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; init; } = 10;
[JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; set; } = 86_400;
[JsonPropertyName("max_visitors")] public int MaxVisitors { get; set; }
[JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; set; } = 10;
public void Validate(ServerLimits limits)
{
+12 -6
View File
@@ -10,8 +10,9 @@ public sealed class ShareService(
ServerLimits limits,
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)
|| !Codec.TryDecodeExact(req.ReadHash, TokenHash.Size, out var readHash)
|| !Codec.TryDecodeExact(req.OwnerHash, TokenHash.Size, out var ownerHash))
@@ -21,7 +22,7 @@ public sealed class ShareService(
settings.Validate(limits);
long now = clock.NowMs;
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 =>
{
@@ -48,6 +49,7 @@ public sealed class ShareService(
return ToInfo(share, tx.ListShareItems(id));
});
public ShareInfo UpdateSettings(Id16 id, Credentials creds, ShareSettingsPatch patch) => store.Write(tx =>
{
var share = Draft(tx.GetShare(id), creds);
@@ -66,7 +68,7 @@ public sealed class ShareService(
throw new WebDropException(ErrorCode.TooLarge);
if (!env.IsEnc) throw new WebDropException(ErrorCode.ModeMismatch);
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 (maxDownloads is < 0) throw new WebDropException(ErrorCode.BadRequest);
@@ -198,7 +200,7 @@ public sealed class ShareService(
var frame = store.Write(tx =>
{
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);
return tx.GetShareFrame(id, manifestId, 0) ?? throw new WebDropException(ErrorCode.Gone);
});
@@ -217,7 +219,7 @@ public sealed class ShareService(
long now = clock.NowMs;
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline))
throw new WebDropException(ErrorCode.Gone);
RegisterVisitor(tx, share, creds.EndpointHash);
RegisterVisitor(tx, share, creds.EndpointHash, creds.AccountName);
if (fromIndex == 0)
{
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)
{
if (share.CreatorAccount != creds.AccountName)
throw new WebDropException(ErrorCode.AccountRequired);
if (creds.ClaimedRole != Role.Owner || !TokenHash.Matches(creds.Token ?? [], share.OwnerHash))
throw new WebDropException(ErrorCode.Unauthorized);
}
@@ -320,8 +324,9 @@ public sealed class ShareService(
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 (tx.HasShareVisitor(share.Id, endpointHash)) return;
if (share.Settings.MaxVisitors > 0 && tx.CountShareVisitors(share.Id) >= share.Settings.MaxVisitors)
@@ -360,6 +365,7 @@ public sealed class ShareService(
ItemId = i.ItemId.ToBase64Url(),
Bytes = i.Bytes,
Frames = i.FrameCount,
Complete = i.IsComplete,
MaxDownloads = i.MaxDownloads,
DownloadsLeft = i.MaxDownloads == 0 ? null : Math.Max(0, i.MaxDownloads - i.Downloads),
}).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>
public sealed class SqliteChannelStore : IResourceStore, IDisposable
{
private const int SchemaVersion = 3;
private const int SchemaVersion = 1;
private readonly string _connectionString;
private readonly SqliteConnection _writeConn;
@@ -31,7 +31,20 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
_writeConn = Open();
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)
@@ -65,7 +78,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
public void Dispose()
{
_writeConn.Dispose();
SqliteConnection.ClearAllPools();
SqliteConnection.ClearPool(_writeConn);
}
private SqliteConnection Open()
@@ -83,23 +96,19 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
cmd.ExecuteNonQuery();
}
private void Migrate()
private void InitializeSchema()
{
using var cmd = _writeConn.CreateCommand();
cmd.CommandText = "PRAGMA user_version;";
long version = (long)cmd.ExecuteScalar()!;
if (version > SchemaVersion) throw new InvalidOperationException("数据库版本高于本程序支持的版本");
if (version == SchemaVersion) return;
if (version != 0) throw new InvalidOperationException("存储版本不兼容当前应用契约");
using var tx = _writeConn.BeginTransaction();
if (version == 0) Exec(_writeConn, SchemaV1);
if (version < 2) Exec(_writeConn, SchemaV2);
if (version < 3) Exec(_writeConn, SchemaV3);
Exec(_writeConn, Schema);
Exec(_writeConn, $"PRAGMA user_version={SchemaVersion};");
tx.Commit();
}
private const string SchemaV1 = """
private const string Schema = """
CREATE TABLE channels (
id BLOB NOT NULL PRIMARY KEY,
mode INTEGER NOT NULL,
@@ -111,7 +120,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
expires_at INTEGER NOT NULL,
last_seq 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);
@@ -119,6 +131,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
channel_id BLOB NOT NULL,
endpoint_hash BLOB NOT NULL,
label BLOB,
account_name TEXT,
joined_at INTEGER NOT NULL,
PRIMARY KEY (channel_id, endpoint_hash)
) WITHOUT ROWID;
@@ -127,6 +140,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
channel_id BLOB NOT NULL,
item_id BLOB NOT NULL,
author_hash BLOB NOT NULL,
author_account TEXT,
flags INTEGER NOT NULL,
max_downloads INTEGER NOT NULL,
downloads INTEGER NOT NULL,
@@ -137,7 +151,6 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
close_deadline INTEGER,
label TEXT,
PRIMARY KEY (channel_id, item_id)
);
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)
) WITHOUT ROWID;
CREATE INDEX ix_log_item ON log(channel_id, item_id);
""";
private const string SchemaV2 = """
CREATE TABLE shares (
id BLOB NOT NULL PRIMARY KEY,
settings TEXT NOT NULL,
@@ -181,7 +192,8 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
expires_at INTEGER NOT NULL,
sealed_at INTEGER,
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);
@@ -222,9 +234,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
PRIMARY KEY (share_id, item_id, frame_index)
) WITHOUT ROWID;
CREATE INDEX ix_share_frames_blob ON share_frames(blob_key) WHERE blob_key IS NOT NULL;
""";
private const string SchemaV3 = """
CREATE TABLE download_grants (
resource_id BLOB NOT NULL,
item_id BLOB NOT NULL,
@@ -232,12 +242,18 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
PRIMARY KEY (resource_id, item_id, endpoint_hash)
) WITHOUT ROWID;
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 const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes";
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 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, author_account";
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 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),
JsonSerializer.Deserialize(r.GetString(1), CoreJsonContext.Default.ShareSettings)!,
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(
"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()));
public bool HasResourceId(Id16 id) => Scalar(
@@ -302,17 +318,17 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
("@id", id.ToArray())) is not null;
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)),
("@read", s.ReadHash), ("@owner", s.OwnerHash), ("@creator", s.CreatorEndpointHash),
("@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(
"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),
("@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(
"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, 4),
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) =>
One($"SELECT {ChannelCols} FROM channels WHERE id=@id", MapChannel, ("@id", id.ToArray()));
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),
("@settings", JsonSerializer.Serialize(c.Settings, CoreJsonContext.Default.ChannelSettings)),
("@rh", c.ReadHash), ("@wh", c.WriteHash), ("@oh", c.OwnerHash),
("@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(
"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) =>
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) =>
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 seats WHERE channel_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) =>
@@ -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(
"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()));
public int CountSeats(Id16 channelId) =>
(int)(long)Scalar("SELECT COUNT(*) FROM seats WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
public void InsertSeat(Id16 channelId, Seat seat) => Run(
"INSERT INTO seats (channel_id, endpoint_hash, label, joined_at) VALUES (@c,@e,@l,@j)",
("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt));
"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), ("@a", seat.AccountName), ("@d", seat.AccountDeviceId));
public bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => Run(
"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()));
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()));
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(
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(9), r.GetInt64(10), r.GetInt64(11), LongOrNull(r, 12),
r.IsDBNull(13) ? null : r.GetString(13));
r.GetInt64(9), r.GetInt64(10), r.GetInt64(11), LongOrNull(r, 12), r.IsDBNull(13) ? null : r.GetString(13));
public Item? GetItem(Id16 channelId, Id16 itemId) => One(
$"SELECT {ItemCols} FROM items WHERE channel_id=@c AND item_id=@i",
MapItem, ("@c", channelId.ToArray()), ("@i", itemId.ToArray()));
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),
("@f", (int)i.Flags), ("@md", i.MaxDownloads), ("@d", i.Downloads),
("@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(
"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) =>
(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(
$"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));
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:服务自己拒绝
await AssertErrorAsync(await s.AppendAsync(ch, ch.Owner, Frame(NewId(), 0, 1, 1_500_000)), 413, "too_large");
// 超过 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]
@@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using WebDrop.Api;
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api.Tests;
@@ -21,6 +22,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
public WebApplication App { get; private set; } = null!;
public HttpClient Http { get; private set; } = null!;
public Uri BaseUri { get; private set; } = null!;
public string OwnerAccountToken { get; private set; } = null!;
public ServerFixture() : this([]) { }
@@ -45,6 +47,12 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
BaseUri = new Uri(address);
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()
@@ -52,7 +60,6 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
Http.Dispose();
await App.StopAsync();
await App.DisposeAsync();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
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);
if (who.Token is not null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", B64(who.Token));
req.Headers.Add("X-WD-Endpoint", B64(who.EndpointSecret));
if (who.Role == "owner") req.Headers.Add("X-WD-Account", OwnerAccountToken);
return req;
}
@@ -129,10 +137,16 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
public async Task<HttpResponseMessage> JoinAsync(Chan ch, Who 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));
if (maxDownloads is not null) req.Headers.Add("X-WD-Max-Downloads", maxDownloads);
req.Headers.ExpectContinue = expectContinue;
return await Http.SendAsync(req);
}
@@ -20,6 +20,7 @@ public class StreamingTests(ServerFixture s) : IClassFixture<ServerFixture>
ws.Options.AddSubProtocol($"wd.role.{who.Role}");
if (who.Token is not null) ws.Options.AddSubProtocol($"wd.auth.{B64(who.Token)}");
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;
using var cts = new CancellationTokenSource(Timeout);
@@ -20,8 +20,8 @@ public class ChannelServiceTests : IDisposable
private static void AssertCode(ErrorCode code, Action act) =>
Assert.Equal(code, Assert.Throws<WebDropException>(act).Code);
private Task<AppendResult> Append(TestChannel ch, Credentials who, byte[] frame, long? maxDownloads = null, string? label = null) =>
Svc.AppendFrameAsync(ch.Id, who, frame, maxDownloads, label);
private Task<AppendResult> Append(TestChannel ch, Credentials who, byte[] frame, long? maxDownloads = null) =>
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) =>
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)),
OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)),
};
AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, _ownerEp));
AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, _ownerEp, "owner"));
}
[Fact]
@@ -55,9 +55,9 @@ public class ChannelServiceTests : IDisposable
var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, OwnerHash = h() };
var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw };
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, _ownerEp));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp, "owner"));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp, "owner"));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, _ownerEp, "owner"));
}
[Fact]
@@ -60,7 +60,7 @@ public class EnvelopeTests
public void RejectsReservedFlagBits()
{
var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 4);
bytes[1] |= 0x10;
bytes[1] |= 0x20;
Assert.False(Envelope.TryParse(bytes, out _));
}
@@ -110,8 +110,17 @@ public class AuthTests
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(
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(
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>
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 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[] OwnerToken { get; } = owner;
public Credentials Owner(byte[] endpoint) => new(Role.Owner, OwnerToken, endpoint);
public Credentials Member(byte[] endpoint) => new(Role.Write, WriteToken, endpoint);
public Credentials Reader(byte[] endpoint) => new(Role.Read, ReadToken, endpoint);
public Credentials Owner(byte[] endpoint) => new(Role.Owner, OwnerToken, endpoint, "owner");
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, endpoint.AsSpan().SequenceEqual(ownerEndpoint) ? "owner" : null);
}
public sealed class TestEnv : IDisposable
@@ -76,9 +76,9 @@ public sealed class TestEnv : IDisposable
ReadHash = mode == ChannelMode.Enc ? Codec.Encode(TokenHash.Compute(read)) : null,
WriteHash = withWriteHash ? Codec.Encode(TokenHash.Compute(write)) : null,
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>
@@ -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) ─────────────
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 MAX_FRAME_DATA = 1048576 - 4;
const LABEL = {
@@ -111,11 +111,13 @@ function metaJson(m) {
if (m.size !== undefined) o.size = m.size;
o.ts = m.ts;
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');
}
/** 条目编码的参照实现:小内容单帧(非 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 head = Buffer.concat([u16(mj.length), mj]);
const cap0 = maxFrameData - head.length;
@@ -123,7 +125,7 @@ function encodeItem({ itemId, meta, content, maxFrameData = MAX_FRAME_DATA, snap
const frames = [];
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 };
}
if (snapshot) throw new Error('快照不能是 GATED');
@@ -216,6 +218,7 @@ vectors.frames = frameCases.map((c) => {
// 条目向量:meta + 内容 + 分帧
const TS = 1700000000000;
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',
itemId: itemA,
@@ -241,6 +244,13 @@ const itemCases = [
meta: { kind: 'file', name: 'note.txt', mime: 'text/plain', size: 11, ts: TS },
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',
itemId: itemB,
@@ -296,6 +306,7 @@ vectors.items = itemCases.map((c) => {
max_frame_data: c.maxFrameData ?? MAX_FRAME_DATA,
snapshot: !!c.snapshot,
force_gated: !!c.forceGated,
control: !!c.control,
gated: plan.gated,
frames: envelopes.map(hex),
};
@@ -381,7 +392,7 @@ vectors.negative = [
{ 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: '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' },
// 没有 ENC 标志的帧,在 enc 会话里必须被拒绝(防降级:服务端不能注入明文帧)
{
+54 -1
View File
@@ -214,6 +214,25 @@
}
],
"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",
"item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf",
@@ -227,6 +246,7 @@
"max_frame_data": 1048572,
"snapshot": false,
"force_gated": false,
"control": false,
"gated": false,
"frames": [
"0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a28e3d72b8892fa67302d87ec18c8cc4c994d9de36816339181f976623149c97875b81538c552b91afc51004ad6e248676e829e4961a7de3055b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67053d1bece4e7ae471253b03e4702dc5b"
@@ -246,6 +266,7 @@
"max_frame_data": 1048572,
"snapshot": false,
"force_gated": false,
"control": false,
"gated": false,
"frames": [
"0109b0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89cae5dcd32135fd5a443bcd7dd5f1c052c986f5512cccd54163df53fbd7a420a42a32a38827e0ccf6a303778ff3dfacb0cad8ceebd64e092bed2663427a091204f6b5f186f71a1bd5c534976aba8b313018197269d810f5bc6fe0ca03bfc9b05a1aec5ddc34855519f3d2d6837f1d859dbe9543e2b188e5cd19037aed96ecb53247ab126e58cee2fcddd50c78cd46453f19a6df767c110a1a59ceaf4090e1f49d3af460d539376d0e173f736b54a871843c2bfc7d18e60d1df6ada80f29ba6e0b5f33753fe162da11ed1c10dff3f0eb3d29c643f02e319cbae99911f3e9dbe163db41e7dbee7b5b92fe07794a1cbaadccf488b7ac1f41328bb02077ca0ad6d1dc4bfd92a072dad9567ddd98455915fb81bf4997a1bd78e4d960b1e944b56746520afd73cabe651625c060da92a499366ec0e81df8e73ab7a1ab6354a97c9807d3f329ce19242b4e16ec4eb65e49af4e4a9c0ed54464522c98b98a30c45b5acce7b76033953602ce26c358c78e1c8875d44e0bae41243089f5408a2a945a8fb408b2dd0b96d05ad14448c3334b2d5c27b73ef4131fb04b1ecda32422ab9620e3a114fe8ef11dd3856d095b8e6974eb53926b293134200336e72a92d665af622fcdec19fd072cfc493905a73dc5d856433293a24c60fdcbe9e7ea45b394e60d2fbfdef69f0d1e4fa430c297e81fa01498b3d9781c33ad80d8c5fa933e81f8bdd3d9afd0d47259970af52e5b01d636ea92d75fbd671b0342434573a83bf919c1d14a55b2f3e3c67e3cf809be5b50ce4bc19571a50c70c6fb86ed9951f98f03d3d6af1fcd5832a148859bb895a91fe9c905816e33df43b5c666b990baa1f97526620a1c87fdf518713222e618815023d5db0b0066dc81f3b13884545d6a510f5d11947e9a89c90baeee052cd3578d14999eac3eb2139e263bb8f3c6a8ccc5e935b2fced80b5803f7bb76a48ddc560758a6d7c6db681fef00a6377afd2d5aa2e77a60fb5bde864f4f180090ba2948e6429a8acbb3be6a5e240521a445b41da8d29eb5f1171dfd3248121f882aa0ea35c5bfa3b464d1088ee841f8c140564076d5ee342b20cde2218aa673c9c55fb3317696692e40d7895b1a8ecf6ecb9d308d72e0ef39bf97bdce8025d02faf27dda3b1cf99ccbaf628d090f195da8e2ddae82df934cfb7862f9d54beb2b2dafe38d5f9950128b387f2b0a6cfbc604c844175c642707ec35f3b27ba693211be15ff995b545ce1d2d16adcd5ca23eb4613bd1cf1c5d13ce9caeb9e154ca0eab40c01b11d6506a9a6a5b0b285ff553903691242b85ad2b5e5718d24b8c8853baf605c44a2156bfc4b734f2d4e1b3caf30299ef464e83a0bbec01ae74743657b63be4d9998a18d49d6a48c926c51c2b6347057eeee6ebe27b7f00d9403729e88734e9b6f65d3b6795fa278765376e8732853173b84d13bb985883794cf8485891a1b2e69d5"
@@ -264,6 +285,7 @@
"max_frame_data": 1048572,
"snapshot": true,
"force_gated": false,
"control": false,
"gated": false,
"frames": [
"010ba0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a59e3d72b8892fa67302d87ec18c8cc4c994d83f57f0c339181f976623149c97875b81538c552b91afc51005991fa4c6d2fc758cbd5242ace3e9544d563cc01a765b6d92c1dbe6a539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab676ca576ae90cf2fe459e7372edf815e5d"
@@ -285,11 +307,37 @@
"max_frame_data": 1048572,
"snapshot": false,
"force_gated": false,
"control": false,
"gated": false,
"frames": [
"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",
"item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
@@ -303,6 +351,7 @@
"max_frame_data": 1048572,
"snapshot": false,
"force_gated": false,
"control": false,
"gated": false,
"frames": [
"0109b0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89cd15ddb32135fd5a443bcd7dd5f1c052c986f5512cccd54163df53fbd7a420a42a32a38827e0ccf6a303729dd599fbd2e97ae0835f507083af20f644fcfff456d16baa0c9990f03b9f7ce933b255489856701a17b1da35bc6fe0ca03bfc9b05a1aec5ddc34855519f3d2d6837f1d859dbe9543e2b188e5cd19037aed96ecb53247ab126e58cee2fcddd50c78cd46453f19a6df767c110a1a59ceaf4090e1f49d3af460d539376d0e173f736b54a871843c2bfc7d18e60d1df6ada80f29ba6e0b5f33753fe162da11ed1c10dff3f0eb3d29c643f02e319cbae99911f3e9dbe163db41e7dbee7b5b92fe07794a1cbaadccf488b7ac1f41328bb02077ca0ad6d1dc4bfd92a072dad9567ddd98455915fb81bf4997a1bd78e4d960b1e944b56746520afd73cabe651625c060da92a499366ec0e81df8e73ab7a1ab6354a97c9807d3f329ce19242b4e16ec4eb65e49af4e4a9c0ed54464522c98b98a30c45b5acce7b76033953602ce26c358c78e1c8875d44e0bae41243089f5408a2a945a8fb408b2dd0b96d05ad14448c3334b2d5c27b73ef4131fb04b1ecda32422ab9620e3a114fe8ef11dd3856d095b8e6974eb53926b293134200336e72a92d665af622fcdec19fd072cfc493905a73dc5d856433293a24c60fdcbe9e7ea45b394e60d2fbfdef69f0d1e4fa430c297e81fa01498b3d9781c33ad80d8c5fa933e81f8bdd3d9afd0d47259970af52e5b01d636ea92d75fbd671b0342434573a83bf919c1d14a55b2f3e3c67e3cf809be5b50ce4bc19571a50c70c6fb86ed9951f98f03d3d6af1fcd5832a148859bb895a91fe9c905816e33df43b5c666b990baa1f97526620a1c87fdf518713222e618815023d5db0b0066dc81f3b13884545d6a510f5d11947e9a89c90baeee052cd3578d14999eac3eb2139e263bb8f3c6a8ccc5e935b2fced80b5803f7bb76a48ddc560758a6d7c6db681fef00a6377afd2d5aa2e77a60fb5bde864f4f180090ba2948e6429a8acbb3be6a5e240521a445b41da8d29eb5f1171dfd3248121f882aa0ea35c5bfa3b464d1088ee841f8c140564076d5ee342b20cde2218aa673c9c55fb3317696692e40d7895b1a8ecf6ecb9d308d72e0ef39bf97bdce8025d02faf27dda3b1cf99ccbaf628d090f195da8e2ddae82df934cfb7862f9d54beb2b2dafe38d5f9950128b387f2b0a6cfbc604c844175c642707ec35f3b27ba693211be15ff995b545ce1d2d16adcd5ca23eb4613bd1cf1c5d13ce9caeb9e154ca0eab40c01b11d6506a9a6a5b0b285ff553903691242b85ad2b5e5718d24b8c8853baf605c44a2156bfc4b734f2d4e1b3caf30299ef464e83a0bbec01ae74743657b63be4d9998a18d49d6a48c926c51c2b6347057eeee6ebe27b7f00d9403729e88734e9b6f65d3b6795fa278765376e8732853173b84cfc631583cbee2e31f9a66775ccc580f"
@@ -324,6 +373,7 @@
"max_frame_data": 1000,
"snapshot": false,
"force_gated": false,
"control": false,
"gated": true,
"frames": [
"010cd0d1d2d3d4d5d6d7d8d9dadbdcdddedf0000000000000410b7c7603f2f1b1aed03d822e5fc9b5febf11d186b28855e37bb162cd659d962897e8eeb6d53a7e6cd2968da60d352d331ad69932f8cc2da20cb690e657ebd55e1cf11f76c561b381deac1b391a4352f0c8ad22e1b37d76cd50a98773742c839de39f7e87afd708fbf99af4d2269ff0dce70c3ba6a72344539a64ef4d0a987d9bbd7d3c4abcafe5b841b500884ca3ab6217edfd0bb4500182e31538f37b41a254f1936c5a1b874ce83e65580a74c673059654d8cf163b33aa2edcc0937a9c485633b5cfcd184004974e33593d956ff9ea3fb1613bcbc99ad68dea96644fd47ba5991094794fb7d47e6f0ed2258bfb85084e2f8d99ae8508cc0cef67825b03adaa0264ae1fc6e78f0f70392f3f25382a2d6379804d1e27399ef2f33895583407371b868adfe6a453caec61fcfedb262f1fdd694f9424361076e0cff98e938e92face2a5165e6e258b15c3430e6c3dda3d7275af7838b3303bc15e60f24ca943da9c05f71beedbb2cba8da4182b6f214fa72922f3478a0a4776ae4a4543368733d81290e28ccdedae01b0e99c81c415d9be80a42e5164f52185b9a615c911baa202234172fbc889e95b5824bf5a9c2db7bb75888b973743ba9bf4d7578129efa973952400819baf67de98bf25586be6117c2120a3b69bd5a90d7fa6d8bfcd372428b7b805163d3babe2008b145489c97cb271aff0e93b7e818c49ff9d4aab3e28dad96d9d61b48b982e13384635f0897b3f136859d778b24be24e07c44057c2fdb995ed1832ada27883b1f33fc0865b10832a326b017d0e56e62ee123a496c265f7b06e87a054816e9689cdf01a65563903d0391e097966c8a4afc3cf2f1bd0b8d1147cd5ac5d0dd4e1ea351c285727fe77e48506b8c6648cf8d3077b9be450203c7c23153827e72a171442dee973bcbb6748911645026828354bf77411d776a0c1822a99f98982f8f9cc6eb4092326d3494fd00c79cbea840aaf4eb79c15a4c28676296f29c0db877a9cc41c852b6c271ae6d5a665612d055b2a7de72017e11ff8a38f5231a61b6af3197e6d62ca8f4448c210a7d70120971dac9047df12e0b39b9782fca7e821d421d46fb53e6a8a6be11badb14b491bed34468aa00537f16eaf15c0de2de52caff1cf3de24aa8611d0250cd24b6bd0258087df18f4ec68dbe157687cf06e64bae9a96d1b53083b8593eb00171455145d069675f77cb17834032d8d16cf1854c37dd4a4b1e1500395af68cb627111035529304e1f1100af2e14d9364974d1eb443ca289c4b3f15b33eb1b00dbf85982add3ac652a2a9fe6d327667dcc0b2be25f0a93a07066a9c9ad1d1c4bfb348a8c551cf6a2d4427201239afc8e664fa2d13920fb05c5c446c82206a9b1d98a2c9fbe12419ba4f29f02e0e64b5c3799f278e7a6ddc4375ba25878596cde0a948d9fdccccbd53ec3b1c3e3922dd672eb64712415b3dbfc6596618f5bb1",
@@ -345,6 +395,7 @@
"max_frame_data": 1000,
"snapshot": false,
"force_gated": false,
"control": false,
"gated": false,
"frames": [
"0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee49f7e3cc2b8892fa67302d87ec18c8cc4c994d8fef7c07339181e3642d6e5ac46a27aa092a8111ab10fd564d1283be143872924ac1c73d4eba52bf61fd06e236f25b8afa123df702458b5567c3ac27d5835623394e87e11bcdb74dad9329d88ca850c9d29627417c6fa0a872582a6dfafbed838ffaee3d5098774eaaa2eede5a6c3bf8d6124e168cd86cdd40e13528699100d8e6d711f5c381621c2211c6f70b2a68fd60bc692e559f51c51ac1043b70f45801fdb1f8d86d1dc75ca54a7b6df9fb8728057308d0d9ad641ded456a80fc9f6d618694ca933ce93e43d16ab46c8c0351ffb65885f6c1ab8bc69194278c3c158dd8faf1189b283da786f3d48b6635cd96ff860ab3331dd50783a958173e0535ec6a44b9688684ea3a9bd5e7c7be84e89e71710a64c16340bc45cde109328446c6924354f165f92d8d5e98fe65e9b7714118b735c7d2b916f012570eedc69af9420e2ad04bd2f39a64602ec2e5364b27261d1e54e8b0b64894ec1828294169fb8b098aa27a27c22c7f941d5af4bd57c7de5cad5276739f457057e19633aa0809153fefab5bcd0e83a6f435905a96a61c52ca082427d51c8f24a8498d086bbc9803c74f9e0f146821f921d5ad786119fd1d387a6552d19d54265ed3b924add48344b7a2122a92ba9125c3f78cd2b53198c7bc191e65e158598d75f57326cb00144b7a14a85864b566e659615e43faedddce2d35492fe01711ec183492e1283f7366b4a6857bfe9110871e70518d7c5a51aadd42ec886318d9bde49222155dea2c40bda0e0e31dee0067630d5d250b1bfa9c17fe155947774b29a175d9fbfb1e9f21abf32d8e12436e8de907afa3a8f16adf40c0ec3d939185ad3ed5a815a87f046604f54590c2c866fc5d1802c13b1d12234dbc92e14e68349c111a20e7bf39d6291aee856ff3c29e86342d67b09d14e084f5bcb887e468ecf3f2c8f1f46d0f3779022db8aaee0ecac0789095406e76e639c7cc1ac949d219701e676cd4c02e5793a7fa7b7b29a56730abc8ed7a52d80425c96e1cd57ed2ff2902f532adecbb18dbaa21ae03b07e82bbc57b75762444bbf6ca0f7b3eb5e0bc2cf0cccfc3c232d6bcfb473303d80c1d5348136c79a34a68ee646f442734a0e52f5c07b78db4ef77b406ba19ce2a278dd0b5b40f0c2ca68360aaf345f42848a0aeaea28e7893ed126c91415c30d54c0ea110f79ffe525abbf9e548f49a05e4f95d9f8ffd38c0481ac682c4c042a0ac5b1716b853f8b2af5fb6f2358dffe9df5300e1a49bfcb8eb31dada59ae98d4c66d9dea53b8f1ec8af980ad28564e30799e276f71c581a9e0cd0d7e2f2b84837cdeb3b1c023e75405b8a33a1e35aa471ef7847013d0f4c7404a46c6a11e96d03d889fdab81462c34e849240de96805d312e566393b33914b30dbb37f32cb4cfbf9f2af171ab675ad6bd81fb6f247b08a9250f920b4660"
@@ -364,6 +415,7 @@
"max_frame_data": 1000,
"snapshot": false,
"force_gated": false,
"control": false,
"gated": true,
"frames": [
"010ca0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee49f7e3cc2b8892fa67302d87ec18c8cc4c994d8fef7c07339181e3642d6e5ac46a27aa092a8111ab10fd564d1283be143872924ac1c73d4eba52bf61fd06e236f25b8afa123df702458b5567c3ac27d5835623394e87e11bcdb74dad9329d88ca850c9d29627417c6fa0a872582a6dfafbed838ffaee3d5098774eaaa2eede5a6c3bf8d6124e168cd86cdd40e13528699100d8e6d711f5c381621c2211c6f70b2a68fd60bc692e559f51c51ac1043b70f45801fdb1f8d86d1dc75ca54a7b6df9fb8728057308d0d9ad641ded456a80fc9f6d618694ca933ce93e43d16ab46c8c0351ffb65885f6c1ab8bc69194278c3c158dd8faf1189b283da786f3d48b6635cd96ff860ab3331dd50783a958173e0535ec6a44b9688684ea3a9bd5e7c7be84e89e71710a64c16340bc45cde109328446c6924354f165f92d8d5e98fe65e9b7714118b735c7d2b916f012570eedc69af9420e2ad04bd2f39a64602ec2e5364b27261d1e54e8b0b64894ec1828294169fb8b098aa27a27c22c7f941d5af4bd57c7de5cad5276739f457057e19633aa0809153fefab5bcd0e83a6f435905a96a61c52ca082427d51c8f24a8498d086bbc9803c74f9e0f146821f921d5ad786119fd1d387a6552d19d54265ed3b924add48344b7a2122a92ba9125c3f78cd2b53198c7bc191e65e158598d75f57326cb00144b7a14a85864b566e659615e43faedddce2d35492fe01711ec183492e1283f7366b4a6857bfe9110871e70518d7c5a51aadd42ec886318d9bde49222155dea2c40bda0e0e31dee0067630d5d250b1bfa9c17fe155947774b29a175d9fbfb1e9f21abf32d8e12436e8de907afa3a8f16adf40c0ec3d939185ad3ed5a815a87f046604f54590c2c866fc5d1802c13b1d12234dbc92e14e68349c111a20e7bf39d6291aee856ff3c29e86342d67b09d14e084f5bcb887e468ecf3f2c8f1f46d0f3779022db8aaee0ecac0789095406e76e639c7cc1ac949d219701e676cd4c02e5793a7fa7b7b29a56730abc8ed7a52d80425c96e1cd57ed2ff2902f532adecbb18dbaa21ae03b07e82bbc57b75762444bbf6ca0f7b3eb5e0bc2cf0cccfc3c232d6bcfb473303d80c1d5348136c79a34a68ee646f442734a0e52f5c07b78db4ef77b406ba19ce2a278dd0b5b40f0c2ca68360aaf345f42848a0aeaea28e7893ed126c91415c30d54c0ea110f79ffe525abbf9e548f49a05e4f95d9f8ffd38c0481ac682c4c042a0ac5b1716b853f8b2af5fb6f2358dffe9df5300e1a49bfcb8eb31dada59ae98d4c66d9dea53b8f1ec8af980ad28564e30799e276f71c581a9e0cd0d7e2f2b84837cdeb3b1c023e75405b8a33a1e35aa471ef7847013d0f4c7404a46c6a11e96d03d889fdab81462c34e849240de96805d312e566393b33914b30dbb37f32cb4cfbf9f2af171ab6752de780d3621900034a5e664d343c68d",
@@ -384,6 +436,7 @@
"max_frame_data": 1048572,
"snapshot": false,
"force_gated": true,
"control": false,
"gated": true,
"frames": [
"010db0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89cbf5dc432135fd5a443bcd7dd5f1c052c986f471ed8dc54163def2df225510750f0347cca3a1ed378747476e768a88d1ea79e3805c537380ac272032ebb9a214d65d7c1a5f50f03b9f7ce933b255489856701a17b1da35bc6fe0ca03bfc9b05a1aec5ddc34855519f3d2d6837f1d859dbe9543e2b188e5cd19037aed96ecb53247ab126e58cee2fcddd50c78cd46453f19a6df767c110a1a59ceaf4090e1f49d3af460d539376d0e173f736b54a871843c2bfc7d18e60d1df6ada80f29ba6e0b5f33753fe162da11ed1c10dff3f0eb3d29c643f02e319cbae99911f3e9dbe163db41e7dbee7b5b92fe07794a1cbaadccf488b7ac1f41328bb02077ca0ad6d1dc4bfd92a072dad9567ddd98455915fb81bf4997a1bd78e4d960b1e944b56746520afd73cabe651625c060da92a499366ec0e81df8e73ab7a1ab6354a97c9807d3f329ce19242b4e16ec4eb65e49af4e4a9c0ed54464522c98b98a30c45b5acce7b76033953602ce26c358c78e1c8875d44e0bae41243089f5408a2a945a8fb408b2dd0b96d05ad14448c3334b2d5c27b73ef4131fb04b1ecda32422ab9620e3a114fe8ef11dd3856d095b8e6974eb53926b293134200336e72a92d665af622fcdec19fd072cfc493905a73dc5d856433293a24c60fdcbe9e7ea45b394e60d2fbfdef69f0d1e4fa430c297e81fa01498b3d9781c33ad80d8c5fa933e81f8bdd3d9afd0d47259970af52e5b01d636ea92d75fbd671b0342434573a83bf919c1d14a55b2f3e3c67e3cf809be5b50ce4bc19571a50c70c6fb86ed9951f98f03d3d6af1fcd5832a148859bb895a91fe9c905816e33df43b5c666b990baa1f97526620a1c87fdf518713222e618815023d5db0b0066dc81f3b13884545d6a510f5d11947e9a89c90baeee052cd3578d14999eac3eb2139e263bb8f3c6a8ccc5e935b2fced80b5803f7bb76a48ddc560758a6d7c6db681fef00a6377afd2d5aa2e77a60fb5bde864f4f180090ba2948e6429a8acbb3be6a5e240521a445b41da8d29eb5f1171dfd3248121f882aa0ea35c5bfa3b464d1088ee841f8c140564076d5ee342b20cde2218aa673c9c55fb3317696692e40d7895b1a8ecf6ecb9d308d72e0ef39bf97bdce8025d02faf27dda3b1cf99ccbaf628d090f195da8e2ddae82df934cfb7862f9d54beb2b2dafe38d5f9950128b387f2b0a6cfbc604c844175c642707ec35f3b27ba693211be15ff995b545ce1d2d16adcd5ca23eb4613bd1cf1c5d13ce9caeb9e154ca0eab40c01b11d6506a9a6a5b0b285ff553903691242b85ad2b5e5718d24b8c8853baf605c44a2156bfc4b734f2d4e1b3caf30299ef464e83a0bbec01ae74743657b63be4d9998a18d49d6a48c926c51c2b6347057eeee6ebe27b7f00d9403729e88734e9b6f65d3b6795fa278765376e8732853173b8443f6f752e2e1fac46a20c4f005725253"
@@ -650,7 +703,7 @@
},
{
"name": "reserved_flag_bit",
"envelope": "0119a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553",
"envelope": "0129a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553",
"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 { JoinScreen, MissingAccess } from '@/features/scan/JoinScreen';
import { JoinScreen } from '@/features/scan/JoinScreen';
import { AccountChannelAccess } from './AccountChannelAccess';
import { useChannels } from '@/store/channels';
import { useShares } from '@/store/shares';
import { useWorkspace } from '@/store/workspace';
@@ -8,6 +9,8 @@ import { ChannelScreen } from './ChannelScreen';
import { ShareScreen } from './ShareScreen';
import { channelPath, navigate, useRoute } from './navigation';
import { Splash } from './Splash';
import { AccountScreen } from '@/features/home/AccountScreen';
import { useAccount } from '@/store/account';
const SEEN_KEY = 'wd.splash';
@@ -20,6 +23,13 @@ function splashSeen(): boolean {
}
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 list = useChannels((s) => s.list);
const channelsHydrated = useChannels((s) => s.hydrated);
@@ -31,12 +41,12 @@ export function App() {
const [splash, setSplash] = useState(() => !splashSeen());
const saved = route.name === 'channel' && list.some((c) => c.id === route.id);
// 只有打开已保存的频道才需要等数据;首页与加入页没有加载过程
// 只有打开已保存的空间才需要等数据;首页与加入页没有加载过程
const waitingOnSavedChannel = route.name === 'channel' && !pendingLink && !channelsHydrated;
const waitingOnSavedShare = route.name === 'share' && pendingShareLink?.id !== route.id && !sharesHydrated;
const ready = route.name === 'home' || (route.name === 'channel'
const ready = accountHydrated && (route.name === 'home' || (route.name === 'channel'
? pendingLink?.id === route.id || (channelsHydrated && (!saved || wsReady))
: pendingShareLink?.id === route.id || sharesHydrated);
: pendingShareLink?.id === route.id || sharesHydrated));
function onSplashDone() {
try {
@@ -48,7 +58,8 @@ export function App() {
}
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') screen = <ShareScreen id={route.id} />;
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 (
<>
+7 -3
View File
@@ -1,13 +1,17 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Workspace } from '@/features/channel/Workspace';
import { useWorkspace } from '@/store/workspace';
import { CreateSheet } from '@/features/home/CreateSheet';
import { channelPath, navigate } from './navigation';
/** 打开(订阅)频道,数据就绪后显示暂存区 */
/** 打开(订阅)空间,数据就绪后显示暂存区 */
export function ChannelScreen({ id }: { id: string }) {
const open = useWorkspace((s) => s.open);
const ready = useWorkspace((s) => s.ready);
const [creating, setCreating] = useState(false);
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 { 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。
*/
export interface WorkspaceApi {
/** 新建频道:本地生成 id 与密钥、算 PoW、POST channels(api.md) */
/** 新建空间:本地生成 id 与密钥、算 PoW、POST channels(api.md) */
createChannel(opts: CreateOptions, captchaToken?: string): Promise<ChannelInfo>;
/** 通过链接加入:先 GET 频道信息预览,再 POST seats */
joinChannel(link: ParsedChannelLink): Promise<ChannelInfo>;
/** 使用现有空间 settings 接口更新空间设置。 */
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[]>;
/** 仅从本机删除保存的链接和终端密钥,不会删服务端频道 */
/** 仅从本机删除保存的链接和终端密钥,不会删服务端空间 */
forget(id: string): Promise<void>;
/** 仅假实现提供:载入演示用的频道 */
/** 仅假实现提供:载入演示用的空间 */
loadDemo?(): ChannelInfo[];
/** 订阅某个频道的事件流(对应追加日志的推送),返回取消订阅函数 */
/** 订阅某个空间的事件流(对应追加日志的推送),返回取消订阅函数 */
connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void;
sendText(text: string): Promise<void>;
sendFiles(files: File[]): Promise<void>;
sendText(text: string, threadId?: string): 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>;
remove(id: 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', () => {
it('accepts compatible SemVer releases in the supported major', () => {
expect(supportsApiContract('1.0.0')).toBe(true);
expect(supportsApiContract('1.8.2')).toBe(true);
expect(supportsApiContract('1.2.0-rc.1')).toBe(true);
expect(supportsApiContract('2.0.0')).toBe(true);
expect(supportsApiContract('2.8.2')).toBe(true);
expect(supportsApiContract('2.2.0-rc.1')).toBe(true);
});
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(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-]+)*)?$/;
+87 -14
View File
@@ -6,8 +6,16 @@ export type ManifestEntry =
| { t: 'text'; localId: string; name?: string; text: string; ts: number };
export interface SavedResource {
creatorAccount?: string;
localScope?: string;
modifiedAt?: number;
needsJoin?: boolean;
id: string;
kind: 'channel' | 'share';
/** 服务端空间设置名称的本地缓存,打开空间时会用最新设置刷新。 */
name?: string;
/** 本终端在席位列表中的自定义显示名称。 */
deviceLabel?: string;
mode: 'enc' | 'raw';
/** Keys stay in IndexedDB. The page URL and localStorage never retain them. */
link: string;
@@ -47,32 +55,88 @@ interface DraftFile {
}
interface WebDropDb extends DBSchema {
resourceDeletes: { key: string; value: { id: string; modifiedAt: number } };
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 } };
}
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>> {
dbPromise ??= openDB<WebDropDb>('webdrop-local', 2, {
upgrade(database, oldVersion) {
let dbPromise = databases.get(resourceScope);
dbPromise ??= openDB<WebDropDb>(`webdrop-2-${resourceScope}`, 2, {
upgrade(database, oldVersion, _newVersion, tx) {
if (oldVersion < 1) {
database.createObjectStore('resources', { keyPath: 'id' });
const outbox = database.createObjectStore('outbox', { keyPath: 'key' });
outbox.createIndex('by-resource', 'resourceId');
}
if (oldVersion < 2) {
const draftFiles = database.createObjectStore('draftFiles', { keyPath: 'key' });
draftFiles.createIndex('by-resource', 'resourceId');
database.createObjectStore('resources', { keyPath: 'id' });
const outbox = database.createObjectStore('outbox', { keyPath: 'key' });
outbox.createIndex('by-resource', 'resourceId');
const draftFiles = database.createObjectStore('draftFiles', { keyPath: 'key' });
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;
}
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> {
@@ -86,13 +150,15 @@ export async function listResources(kind: SavedResource['kind']): Promise<SavedR
export async function removeResource(id: string): Promise<void> {
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('resourceDeletes').put({ id, modifiedAt: Date.now() });
const keys = await tx.objectStore('outbox').index('by-resource').getAllKeys(id);
await Promise.all(keys.map((key) => tx.objectStore('outbox').delete(key)));
const fileKeys = await tx.objectStore('draftFiles').index('by-resource').getAllKeys(id);
await Promise.all(fileKeys.map((key) => tx.objectStore('draftFiles').delete(key)));
await tx.done;
resourceChangeListener?.();
}
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> {
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];
}
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> {
const database = await db();
const tx = database.transaction('outbox', 'readwrite');
+6 -2
View File
@@ -1,13 +1,17 @@
const messages: Record<string, string> = {
not_found: '内容不存在或已过期。', gone: '内容已删除、下载次数已用完或续传已失效。',
seats_full: '频道席位已满,请联系所有者。', not_joined: '你已被移出此频道。',
seats_full: '空间席位已满,请联系所有者。', not_joined: '你已被移出此空间。',
visitors_full: '分享的访客名额已满。', unauthorized: '链接权限无效,请使用完整链接重新打开。',
forbidden: '此链接没有执行此操作的权限。', sealed: '分享已封存,无法再修改。',
too_large: '内容超过服务端容量或条目数量限制。', rate_limited: '请求过于频繁,请稍后重试。',
captcha_required: '验证码未通过或已失效,请重新创建。',
captcha_unavailable: '验证码服务暂时不可用,请稍后重试。',
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 {
+74 -10
View File
@@ -3,7 +3,7 @@ import { previewKindOf } from '@/lib/fileKind';
import type { WorkspaceApi } from './api';
import { b64url } from './mockLinks';
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));
@@ -28,9 +28,9 @@ interface Runtime {
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 {
const runtimes = new Map<string, Runtime>();
const timers = new Set<number>();
@@ -68,7 +68,7 @@ export function createMockApi(): WorkspaceApi {
return rt;
}
/** 演示频道里的实时动静:iPhone 发来消息、改了文件、短暂编辑便签 */
/** 演示空间里的实时动静:iPhone 发来消息、改了文件、短暂编辑便签 */
function runDemoActivity(rt: Runtime) {
later(9000, () => put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text: '到楼下了' }));
later(14000, () => {
@@ -82,13 +82,16 @@ export function createMockApi(): WorkspaceApi {
}
return {
async inviteUser() {},
async listInvitations() { return []; },
async revokeInvitation() {},
async listSaved() { return []; },
async forget() {},
async createChannel(opts) {
await wait(300);
const id = b64url(16);
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]);
return info;
},
@@ -106,12 +109,16 @@ export function createMockApi(): WorkspaceApi {
mode: link.mode,
role: link.isOwner ? 'owner' : 'member',
seats: 3,
seatsUsed: 1,
membersCanUpload: true,
maxDownloads: 10,
ttlSeconds: 86400,
createdAt: Date.now(),
expiresAt: Date.now() + 86400_000,
memberLink: member ? `${base}#${member}` : base,
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;
},
@@ -140,16 +147,48 @@ export function createMockApi(): WorkspaceApi {
};
},
async sendText(text) {
put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: SELF, state: { kind: 'complete' }, text });
async sendText(text, threadId) {
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) {
const id = uid();
const base: FileItem = {
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),
};
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) {
const cur = current?.items.get(id);
if (cur?.kind !== 'text') return;
+4 -3
View File
@@ -9,9 +9,9 @@ export const DEMO_ID = 'k3Fq9xWm2LpA7sTdQ8vZ1n';
const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
export const devices: Device[] = [
{ id: SELF, name: '本机 · Chrome', kind: 'desktop', online: true, self: true },
{ id: PHONE, name: 'iPhone', kind: 'phone', online: true },
{ id: 'tablet', name: 'iPad', kind: 'tablet', online: false, lastSeen: Date.now() - 3 * 3600_000 },
{ id: SELF, name: '本机 · Chrome', kind: 'desktop', self: true, joinedAt: Date.now() },
{ id: PHONE, name: 'iPhone', kind: 'phone', joinedAt: Date.now() - 3600_000 },
{ id: 'tablet', name: 'iPad', kind: 'tablet', joinedAt: Date.now() - 3 * 3600_000 },
];
export const channel: ChannelInfo = {
@@ -19,6 +19,7 @@ export const channel: ChannelInfo = {
mode: 'enc',
role: 'owner',
seats: 3,
seatsUsed: 3,
createdAt: Date.now() - 3 * 3600_000,
expiresAt: Date.now() + 21 * 3600_000,
memberLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}`,
+20 -5
View File
@@ -1,9 +1,10 @@
import type { ParsedChannelLink } from '@/lib/channelLink';
import type { AccountInvitation } from './account';
import type { ParsedShareLink } from '@/lib/shareLink';
import type { WorkspaceApi } from './api';
import type { ShareApi } from './shareApi';
import { callWorker, subscribeWorker } from './workerClient';
import type { ArchiveEntry, ChannelInfo, ConnectionState, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types';
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';
@@ -15,7 +16,13 @@ function reportChannelUnavailable(id: string, onEvent: (event: WorkspaceEvent) =
export const realApi: WorkspaceApi = {
createChannel: (options: CreateOptions, captchaToken?: string) => callWorker<ChannelInfo>('createChannel', { options, captchaToken, apiBase: API_BASE }),
joinChannel: (link: ParsedChannelLink) => callWorker<ChannelInfo>('joinChannel', { link, apiBase: API_BASE }),
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 }),
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 }),
sendFiles: (files) => callWorker<void>('sendFiles', { id: currentChannelId(), files, apiBase: API_BASE }),
editText: (id, text) => callWorker<void>('editChannelText', { id: currentChannelId(), itemId: id, text, apiBase: API_BASE }),
sendText: (text, threadId) => callWorker<void>('sendText', { id: currentChannelId(), text, threadId, apiBase: API_BASE }),
sendFiles: (files, options, threadId, folderId) => callWorker<void>('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, 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 }),
remove: (id) => callWorker<void>('removeChannelItem', { id: currentChannelId(), itemId: id, 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';
/**
* 便捷分享的后端边界。和频道分开:分享没有设备、便签、冲突与历史,只有
* 便捷分享的后端边界。和空间分开:分享没有设备、便签、冲突与历史,只有
* 「草稿 → 封存 → 过期」。线路上仍是 channels(成员不可上传),所以由同一个 core 会话实现。
*/
export interface ShareApi {
+59 -6
View File
@@ -6,19 +6,29 @@ export interface Device {
id: string;
name: string;
kind: DeviceKind;
online: boolean;
self?: boolean;
/** 正在编辑便签 */
editing?: boolean;
lastSeen?: number;
joinedAt?: number;
}
/** 常驻频道:自己多台设备之间长期同步 */
/** 常驻空间:自己多台设备之间长期同步 */
export interface ChannelInfo {
creatorAccount?: string;
joinPolicy?: 'open' | 'invite';
allowGuests?: boolean;
passwordRequired?: boolean;
id: string;
/** 服务端 channel settings 中的共享名称,加入者可见。 */
name?: string;
mode: 'enc' | 'raw';
role: 'owner' | 'member';
seats: number;
seatsUsed?: number;
membersCanUpload?: boolean;
ttlSeconds?: number;
/** 每个新上传文件的默认下载次数上限。 */
maxDownloads?: number;
memberLink: string;
/** 只有所有者有 */
ownerLink?: string;
@@ -27,12 +37,34 @@ export interface ChannelInfo {
canUpload?: boolean;
}
/** 新建频道的选项(protocol §12 设置的子集) */
/** 新建空间的选项(protocol §12 设置的子集) */
export interface CreateOptions {
joinPolicy?: 'open' | 'invite';
allowGuests?: boolean;
joinPassword?: string;
name?: string;
mode: 'enc' | 'raw';
seats: number;
ttlSeconds: number;
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:由帧推导,不单独存储 */
export type ItemState =
| { kind: 'receiving'; progress: number }
| { kind: 'receiving'; progress: number; phase?: 'encrypting' | 'uploading'; speedBytesPerSecond?: number }
| { kind: 'complete' }
| { kind: 'incomplete' }
| { kind: 'tampered' };
interface ItemBase {
author?: SenderInfo;
id: string;
ts: number;
deviceId: string;
state: ItemState;
threadId?: string;
folderId?: string;
}
export interface SenderInfo { username: string; displayName: string; avatar?: string }
export interface TextItem extends ItemBase {
kind: 'text';
text: string;
@@ -94,6 +131,8 @@ export interface FileItem extends ItemBase {
remaining: number | null;
preview: PreviewKind;
gated?: boolean;
/** announce-only placeholder; omit thread/folder/name until explicit item read. */
metadataHidden?: boolean;
/**
* 旧版本,新的在前,不含当前版本。
* 约定(待写入 protocol §7):新版本是一个新条目,其加密的 meta_json 带 `replaces`,
@@ -119,13 +158,17 @@ export interface ArchiveEntry {
dir: boolean;
}
/** protocol §9:频道唯一的快照槽,最后写入者胜 */
/** protocol §9:空间唯一的快照槽,最后写入者胜 */
export interface Snapshot {
text: string;
updatedBy: string;
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 =
| { t: 'reset' }
| { t: 'error'; message: string }
@@ -134,7 +177,17 @@ export type WorkspaceEvent =
| { t: 'channel'; info: ChannelInfo }
| { t: 'devices'; devices: Device[] }
| { t: 'item'; item: Item }
| { t: 'file-catalog'; item: FileItem }
| { t: 'item-author'; id: string; author: SenderInfo }
| { 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 };
export type ShareEvent =
+38 -9
View File
@@ -1,6 +1,6 @@
import { setApiBase } from './worker/http';
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 { stopChannel } from './worker/channelTransport';
import { addShareFiles, addShareText, editShareText, removeShareItem } from './worker/shareDraft';
@@ -10,6 +10,9 @@ import { sealShare } from './worker/shareSeal';
import { setEventSink } from './worker/port';
import { serial } from './worker/serial';
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 WorkerScope {
@@ -23,20 +26,50 @@ setEventSink((resourceId, event) => scope.postMessage({ resourceId, event }));
async function dispatch(operation: string, input: unknown): Promise<unknown> {
const payload = input as Record<string, unknown>;
if (typeof payload.apiBase === 'string') setApiBase(payload.apiBase);
await restoreAccount();
const id = typeof payload.id === 'string' ? payload.id : '';
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 '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 'previewChannel': return previewChannel(payload.link as Parameters<typeof previewChannel>[0]);
case 'channelMembers': return channelMembers(id);
case 'listChannels': return listChannels();
case 'forgetChannel': return forgetChannel(id);
case 'connectChannel': void connectChannelResource(id); return true;
case 'stopChannel': stopChannel(id); return true;
case 'sendText': return sendText(id, String(payload.text ?? ''));
case 'sendFiles': return sendFiles(id, payload.files as File[]);
case 'sendText': return sendText(id, String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined);
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 'getChannelBlob': return getChannelBlob(id, String(payload.itemId), typeof payload.versionId === 'string' ? payload.versionId : undefined);
case 'updateSnapshot': return updateSnapshot(id, String(payload.text ?? ''));
case 'editChannelText': return editChannelText(id, String(payload.itemId), String(payload.text ?? ''));
case 'editChannelText': return editChannelText(id, String(payload.itemId), String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined);
case 'resolveTextConflict': return resolveTextConflict();
case 'markChannelSeen': return markChannelSeen();
case 'listArchive': return listArchive(id);
@@ -59,11 +92,7 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
scope.onmessage = (event) => {
const request = event.data;
const payload = request.payload as { id?: string; link?: { id: string } };
const key = payload.id ?? payload.link?.id;
const readOnly = /^(connect|stop|list|get)/.test(request.operation);
const task = key && !readOnly ? serial(key, () => dispatch(request.operation, request.payload))
: dispatch(request.operation, request.payload);
const task = serial('account-scope', () => dispatch(request.operation, request.payload));
void task.then(
(result) => scope.postMessage({ requestId: request.requestId, result }),
(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;
}
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff. Show more