feat(api): bind encrypted workspaces to accounts and devices

This commit is contained in:
justme committed 2026-10-13 01:38:19 +08:00
1 parent 019b15041a
commit c7d8ef15bc
49 files changed
+1900 -274

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
{
"version": "1.0.0"
"version": "2.1.0"
}
+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 合并,不写入服务端明文账号表。昵称不改变固定用户名或参与密钥派生。
+20 -7
View File
@@ -2,6 +2,8 @@
## 1. 组件
账号、设备、频道、thread 与内容加密的统一边界见 [account-channel-architecture.md](account-channel-architecture.md)。该文档明确账号成员与设备会话分离、按账号领取频道密钥包及加密文件目录。
```
浏览器 (React, 独立托管或 API 同源嵌入) 服务端 (.NET 10, NativeAOT)
┌──────────────────────────────┐ ┌─────────────────────────────┐
@@ -9,10 +11,10 @@
│ @webdrop/core (加解密、帧) │ 密文 + 令牌 │ 鉴权: 只比对令牌哈希 │
│ Worker: 流式加解密 │◄───────────►│ 日志: 分配 seq、幂等、推送 │
│ IndexedDB: 发送队列、本地收藏 │ │ BackgroundService: 过期清理 │
│ Service Worker: 流式下载 │ └──────┬───────────┬──────────┘
│ OPFS: 逐块写入临时文件 │ └──────┬───────────┬──────────┘
└──────────────────────────────┘ │ │
SQLite IBlobStore
(元数据、日志) (磁盘,预留 S3)
(频道、账号密文库) (磁盘,预留 S3)
```
服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。可选的 `WebDrop.Frontend` 模块只提供嵌入的静态资源,不访问频道存储或加解密代码。
@@ -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`),创建者终端不写入此表 |
@@ -97,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. 阶段划分
@@ -111,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 和测试向量,再改代码。
@@ -122,3 +133,5 @@ SQLite 存元数据和小帧,磁盘存大帧。
- 服务端日志中没有 K、令牌和频道 id 明文。
- 抓包确认请求里没有 K 和明文。
- AOT 发布产物能在没有 .NET 运行时的机器上运行。
界面术语采用「空间 → 频道分组 → 频道 → 文件夹/聊天」。内部 Channel/Thread 命名保留,分别映射空间/频道;分组沿用加密 CONTROL 日志,不引入新的成员或密钥体系。
+376 -11
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
@@ -694,7 +1047,7 @@ components:
required: [protocol, contract_version, limits, captcha, transports]
properties:
protocol: { type: integer, const: 1 }
contract_version: { type: string, example: 1.0.0, description: API 契约的 SemVer。破坏性变更递增主版本。 }
contract_version: { type: string, example: 2.0.0, description: API 契约的 SemVer。破坏性变更递增主版本。 }
captcha:
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 生命周期处理,实际消息可见性仍由条目和墓碑决定。
+18 -2
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,10 +57,16 @@
| 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 的页面。
隔离部署的缓解:
@@ -81,6 +88,8 @@
- **不提供不可否认性**:帧没有逐发送者签名,所有成员共享同一密钥。
- **不防已被攻破的终端**。
- **不做口令叠加、后量子**,留待后续版本。
- **账号密码不是频道访问控制**。账号保险库只同步持有的资源链接;拿到频道链接的人仍按频道自身能力访问。
- **无密码重置**。忘记密码会失去保险库解密能力;未来若需要找回,须增加独立恢复密钥,而不能由服务端重置后解密旧保险库。
## 7. 服务端内存与日志的要求
@@ -88,3 +97,10 @@
- 令牌只做哈希比对,使用常量时间比较,不写日志。
- 日志不记录请求头、请求体、完整 URL;频道 id 在日志中截断或哈希。
- 错误信息不回显令牌和任何用户提交的字节。
## 8. 账号设备与本机缓存
同一账号的各设备拥有独立 sessions 和频道 S。撤销设备会删除该设备 sessions 与频道席位;不会移除账号成员或其他设备。撤销不能擦除已下载数据、已持有的频道 K 或被复制的账号私钥。当前未实现密钥轮换,不声称前向保密。
本机记住登录允许本浏览器使用者访问账号,退出时删除自动恢复凭据。OPFS 存储解密后临时文件,正常退出账号时清理;浏览器崩溃可能留下本机缓存,它不属于服务端密文。
-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> {
+19 -43
View File
@@ -1,4 +1,4 @@
import { Flags, MAX_FRAME_DATA, RAW_MAX_PAYLOAD, type ItemKind } from './constants.js';
import { Flags, MAX_FRAME_DATA, type ItemKind } from './constants.js';
import { concat, readU16, toBytes, tryDecodeUtf8, utf8, writeU16, toBase64Url, type Bytes } from './bytes.js';
import { 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>;
@@ -256,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;
@@ -281,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,
@@ -326,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,
},
+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);
+6 -4
View File
@@ -24,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>();
@@ -42,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))));
}
@@ -64,6 +65,7 @@ public static class WebDropApp
});
var app = builder.Build();
_ = app.Services.GetRequiredService<SqliteChannelStore>();
if (options.TrustForwardedHeaders)
{
@@ -82,7 +84,7 @@ public static class WebDropApp
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat });
app.UseRateLimiter();
EmbeddedFrontend.Use(app, options.Captcha.ApiEndpoint);
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>());
app.MapApi(options, app.Services.GetRequiredService<ICaptchaVerifier>(), app.Services.GetRequiredService<SqliteAccountStore>());
return app;
}
+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,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 _));
}
}
@@ -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;
}
@@ -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"
},
{
+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-]+)*)?$/;