Compare commits
3
Commits
b1ac11e1d7
...
12980ba6fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12980ba6fe | ||
|
|
8233369cd4 | ||
|
|
c858bd2a09 |
No files matched your search
@@ -35,7 +35,7 @@
|
||||
|
||||
- **频道**:唯一的顶层概念,一条只追加的帧流,有自己的 id、密钥、设置和账号成员人数上限。
|
||||
- **条目**:频道里的一项内容(文本、JSON、文件),由若干帧组成,可复制、下载、删除。
|
||||
- **成员**:频道里的人是用户(账号);设备只是账号的属性,同一账号的多设备只算一人,请求凭账号会话鉴权。未登录的**访客**以终端密钥 `S` 登记,每个访客终端各算一人。
|
||||
- **成员**:频道里的人是用户(账号);设备只是账号的属性,同一账号的多设备只算一人,请求凭账号会话鉴权。频道强制登录,没有未登录访客(便捷分享不受影响)。
|
||||
- **所有者 / 成员**:所有者持有管理密钥,成员通过频道链接加入。
|
||||
- **模式**:`enc`(默认,浏览器端 AES-256-GCM)和 `raw`(负载原样,服务器可见,用于公开或用户自行加密的内容)。
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "7.0.0"
|
||||
"version": "8.0.0"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"x-backend-contract-sha256": "54ea3f00fa672e0241efbb9fc8e7ad9e8fc68415ec124e08f70657c2d57d9ff9",
|
||||
"x-backend-contract-sha256": "655e918f3443073c270bdeb9ae4e69d79dacf83baa90dea1a0cdcfe71d2b8bd5",
|
||||
"$defs": {
|
||||
"AccountChannelBody": {
|
||||
"type": "object",
|
||||
@@ -362,9 +362,6 @@
|
||||
"ChannelSettings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow_guests": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"delete_policy": {
|
||||
"$ref": "#/$defs/DeletePolicy"
|
||||
},
|
||||
@@ -401,7 +398,6 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"allow_guests",
|
||||
"delete_policy",
|
||||
"file_max_downloads",
|
||||
"join_policy",
|
||||
@@ -415,16 +411,6 @@
|
||||
"ChannelSettingsPatch": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow_guests": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete_policy": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -841,21 +827,6 @@
|
||||
"oldest_seq"
|
||||
]
|
||||
},
|
||||
"GuestInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"endpoint_hash": {
|
||||
"type": "string"
|
||||
},
|
||||
"joined_at": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"endpoint_hash",
|
||||
"joined_at"
|
||||
]
|
||||
},
|
||||
"InvitationBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+21
-21
@@ -2,9 +2,9 @@
|
||||
|
||||
账户资料使用 `GET/PATCH /accounts/profile`;头像为 PNG/JPEG/WebP data URI,禁止远程 URL 和 SVG。不提供旧资料导入。
|
||||
空间结构使用 `GET /spaces/{id}/structure`,包含 revision、channels、groups、folders、locations。频道与分组分别通过 `/spaces/{id}/channels` 和 `/spaces/{id}/groups` 创建;响应同时包含完整结构快照和服务端新建实体及 ID。其他字段通过 PATCH 重命名;频道归属通过 `PUT /spaces/{id}/channels/{channelId}/group` 修改。文件夹通过 `/spaces/{id}/folders` 创建及 PATCH 重命名,文件归属通过 `PUT /spaces/{id}/locations` 修改。
|
||||
频道、分组和目录写操作需要账号会话和空间写权限;允许上传的访客可以关联和移动自己的资源,文件移动必须满足每个条目的作者或管理权限,跨空间、跨频道的错误分组/目录关联被拒绝。结构修改后通知订阅设备重新拉取。
|
||||
频道、分组和目录写操作需要账号会话和空间写权限;允许上传的成员可以关联和移动自己的资源,文件移动必须满足每个条目的作者或管理权限,跨空间、跨频道的错误分组/目录关联被拒绝。结构修改后通知订阅设备重新拉取。
|
||||
|
||||
URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `7.0.0`。v7 把「设备」收束为账号的属性:成员只按用户管理,`seats` 系列命名改为 `max_members` / `members_used` / `members_full`,去掉设备名与设备清单接口,访客改走 `/guests`,登录账号不再需要终端密钥。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;公开链接仍允许访客按频道加入规则访问。
|
||||
URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `8.0.0`。v8 让频道强制登录:去掉未登录访客(设置 `allow_guests`、`/guests` 接口、访客终端席位),频道与空间的所有接口都要求账号会话,缺少时返回 `401 account_required`;便捷分享(`/shares`)不受影响。v7 把「设备」收束为账号的属性:成员只按用户管理,`seats` 系列命名改为 `max_members` / `members_used` / `members_full`,去掉设备名与设备清单接口,登录账号不再需要终端密钥。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;频道链接只对登录账号有效,未登录的人先登录再按加入规则加入。
|
||||
|
||||
`GET /info` 的 `defaults` 包含 `channel`、`share` 创建设置;省略创建请求的 settings 时服务端使用相同默认值。`choices.max_members` 与 `limits.max_members` 表示成员人数范围,不代表设备数。频道设置 JSON 字段为 `max_members`(旧存储里的 `seats` 在启动时迁移)。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。
|
||||
|
||||
@@ -16,24 +16,24 @@ URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `7.0.0`。v7 把
|
||||
|---|---|
|
||||
| `X-WD-Role` | `read` / `write` / `owner`,指明 `Authorization` 里的令牌属于哪种角色 |
|
||||
| `Authorization` | `Bearer <base64url(令牌)>` |
|
||||
| `X-WD-Endpoint` | `base64url(S)`,终端密钥。**只有未登录访客必填**;登录账号以 `X-WD-Account` 会话鉴权,可省略 |
|
||||
| `X-WD-Endpoint` | `base64url(S)`,终端密钥。**只用于便捷分享**(未登录的创建者与读者);频道与空间不使用,可省略 |
|
||||
| `X-WD-Cap` | 创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11) |
|
||||
| `X-WD-Max-Downloads` | 第 0 帧上带,条目的下载上限,0 为不限 |
|
||||
| `X-WD-Account` | 账号 session token。与频道/分享的能力令牌分开;创建、管理和账号成员请求必填,访客省略 |
|
||||
| `X-WD-Account` | 账号 session token。与频道/分享的能力令牌分开;频道、空间、创建和管理请求必填 |
|
||||
| `X-WD-Event-*` | 已登记签名设备提交频道对象 FINAL 帧时必填,绑定对象 ID、域/epoch、事件类型与完整密文帧 manifest,见 protocol §21 |
|
||||
|
||||
raw 模式的读请求不需要令牌,但仍需要 `X-WD-Endpoint`。
|
||||
raw 模式的读请求不需要令牌,但仍需要账号会话。
|
||||
|
||||
WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, wd.role.<role>, wd.auth.<base64url(令牌)>, wd.ep.<base64url(S)>`,服务端回选 `wd.v1`。不要放进 URL,否则会被代理记录。
|
||||
WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, wd.role.<role>, wd.auth.<base64url(令牌)>, wd.account.<base64url(账号 session)>`,服务端回选 `wd.v1`。不要放进 URL,否则会被代理记录。
|
||||
|
||||
## 2. 权限矩阵
|
||||
|
||||
| 操作 | 需要 |
|
||||
|---|---|
|
||||
| 读日志、拉取条目 | `read`(enc)或知道 id(raw),并已是成员(账号)或已加入的访客终端 |
|
||||
| 读日志、拉取条目 | `read`(enc)或知道 id(raw),并已是该频道的成员(账号) |
|
||||
| 追加帧 | `write`(`members_can_upload` 为 true 时)或 `owner` |
|
||||
| 删除条目 | `owner`;`delete_policy=own` 时账号等于条目作者(访客为终端哈希等于条目作者);`anyone` 时任意成员 |
|
||||
| 改设置、删频道、看和移除访客 | `owner`;这些管理操作不占成员名额 |
|
||||
| 删除条目 | `owner`;`delete_policy=own` 时账号等于条目作者;`anyone` 时任意成员 |
|
||||
| 改设置、删频道、撤销成员 | `owner`;这些管理操作不占成员名额 |
|
||||
|
||||
## 3. 接口
|
||||
|
||||
@@ -77,16 +77,16 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
|
||||
raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创建者自动成为频道的所有者成员。返回 `201`,响应体与「读取频道信息」相同(`ChannelInfo`)。
|
||||
|
||||
### `GET /api/v1/channels/{id}`
|
||||
返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、成员名额占用(`members_used`)、`oldest_seq`、`last_seq`、时间。`members_used` 统计唯一账号成员和匿名访客终端,不统计账号的设备数。**不占成员名额**,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。
|
||||
返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、成员名额占用(`members_used`)、`oldest_seq`、`last_seq`、时间。`members_used` 统计唯一账号成员,不统计账号的设备数。**不占成员名额**,需要账号会话,用于加入前预览和核对模式。频道名是普通设置,服务端可见。
|
||||
|
||||
### `POST /api/v1/channels/{id}/join`
|
||||
加入频道。登录账号只登记成员关系:账号第一次加入时占一个成员名额,同一账号的更多设备不增加名额、也不登记终端,此后仅凭账号会话鉴权(`X-WD-Endpoint` 可省略)。未登录访客登记一个终端(`X-WD-Endpoint` 必填),每个访客终端各计一个成员名额。同一账号或同一访客终端重复调用幂等。成员名额已满时返回 `403 members_full`。读取频道信息和 owner 管理接口不占成员名额;访客访问频道内容前必须已加入,否则返回 `403 not_joined`。响应 `JoinResult`:`{joined_at, members_used, members_max}`,不再返回终端标识。
|
||||
加入频道,必须带账号会话(没有则 `401 account_required`)。只登记成员关系:账号第一次加入时占一个成员名额,同一账号的更多设备不增加名额,此后仅凭账号会话鉴权。同一账号重复调用幂等。成员名额已满时返回 `403 members_full`。读取频道信息和 owner 管理接口不占成员名额;访问频道内容前必须已加入,否则返回 `403 not_joined`。响应 `JoinResult`:`{joined_at, members_used, members_max}`,不再返回终端标识。
|
||||
|
||||
可发送 JSON `{password}`。默认 `join_policy=open,allow_guests=true`,允许持链接的陌生人设备加入。`allow_guests=false` 要求账号登录;`join_policy=invite` 要求该账号已受邀。频道主、已加入的账号成员和受邀账号无需再次输入频道密码。密码由服务端保存加盐 PBKDF2 哈希,从不通过频道信息返回。设备是账号的属性:同一账号可在任意多台设备登录,只占一个成员名额;账号成员的读写请求出示有效账号 session 即可,不再绑定到某个终端。
|
||||
可发送 JSON `{password}`。`join_policy=open` 允许持链接的登录账号加入;`join_policy=invite` 要求该账号已受邀。频道主、已加入的账号成员和受邀账号无需再次输入频道密码。密码由服务端保存加盐 PBKDF2 哈希,从不通过频道信息返回。设备是账号的属性:同一账号可在任意多台设备登录,只占一个成员名额;账号成员的读写请求出示有效账号 session 即可,不再绑定到某个终端。
|
||||
|
||||
`GET /channels/{id}/members` 返回频道内账号成员及 `owner|member` 角色,每项附带账号级 `online`(该账号在本频道有活动 WebSocket/SSE 订阅)、`devices`(该账号的登录设备数,不小于 `online_devices`)与 `online_devices`(该账号此刻在本频道有订阅的设备数);设备是账号的属性,不单独作为成员。在线状态只来自内存里的订阅登记,不写库、不经订阅流推送,客户端按需轮询;访客没有账号身份,不参与统计。`GET /channels/{id}/invitations` 不再返回已经成为成员的账号。邀请前,频道主调用 `GET /channels/{id}/invitations/{username}/crypto-plan` 获取服务端根据成员权限和历史设置计算的域/epoch/收件人计划。浏览器只为计划中的收件人生成并封装域密钥;随后 `POST /channels/{id}/invitations` 原子提交账号链接包与 `domain_envelopes`。服务端会重新计算计划并校验每个 epoch 和收件人,不接受前端自行增加授权或选择轮换范围。`DELETE /channels/{id}/invitations/{username}` 撤销邀请、成员授权与域密钥包,并阻止重新加入,直到再次邀请。不能移除频道主。移除访客用 `DELETE /channels/{id}/guests/{endpoint_hash}`,同样阻止该终端重新加入。
|
||||
`GET /channels/{id}/members` 返回频道内账号成员及 `owner|member` 角色,每项附带账号级 `online`(该账号在本频道有活动 WebSocket/SSE 订阅)、`devices`(该账号的登录设备数,不小于 `online_devices`)与 `online_devices`(该账号此刻在本频道有订阅的设备数);设备是账号的属性,不单独作为成员。在线状态只来自内存里的订阅登记,不写库、不经订阅流推送,客户端按需轮询。`GET /channels/{id}/invitations` 不再返回已经成为成员的账号。邀请前,频道主调用 `GET /channels/{id}/invitations/{username}/crypto-plan` 获取服务端根据成员权限和历史设置计算的域/epoch/收件人计划。浏览器只为计划中的收件人生成并封装域密钥;随后 `POST /channels/{id}/invitations` 原子提交账号链接包与 `domain_envelopes`。服务端会重新计算计划并校验每个 epoch 和收件人,不接受前端自行增加授权或选择轮换范围。`DELETE /channels/{id}/invitations/{username}` 撤销邀请、成员授权与域密钥包,并阻止重新加入,直到再次邀请。不能移除频道主。移除访客用 `DELETE /channels/{id}/guests/{endpoint_hash}`,同样阻止该终端重新加入。
|
||||
|
||||
频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号会话(或访客终端)、频道成员关系与写入权限,不能靠客户端隐藏按钮绕过。移除账号成员才会释放成员名额;撤销账号的某台登录设备只使该设备的会话失效,不影响成员身份。WebSocket 账号 session 经 `wd.account.<base64url(token)>` 子协议传递,并在推送循环中重新检查有效性。
|
||||
频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号会话、频道成员关系与写入权限,不能靠客户端隐藏按钮绕过。移除账号成员才会释放成员名额;撤销账号的某台登录设备只使该设备的会话失效,不影响成员身份。WebSocket 账号 session 经 `wd.account.<base64url(token)>` 子协议传递,并在推送循环中重新检查有效性。
|
||||
|
||||
### `PATCH /api/v1/channels/{id}/settings`
|
||||
所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`max_members` 调整成员人数上限,不能改变已有成员身份;`ttl_seconds` 的修改从现在起算。
|
||||
@@ -112,7 +112,7 @@ raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创
|
||||
### `GET /api/v1/channels/{id}/ws?from=<seq>`
|
||||
WebSocket 订阅(降级链的第一级)。服务端向下推送:
|
||||
- 二进制消息:一条日志记录。
|
||||
- 文本消息(JSON 控制消息):`{"t":"hb","seq":N}` 心跳,`{"t":"settings"}` 设置变更,`{"t":"members"}` 成员/访客变更。
|
||||
- 文本消息(JSON 控制消息):`{"t":"hb","seq":N}` 心跳,`{"t":"settings"}` 设置变更,`{"t":"members"}` 成员变更。
|
||||
|
||||
上传仍走 HTTP `POST`,便于幂等重试。
|
||||
|
||||
@@ -123,15 +123,14 @@ SSE 订阅(降级链的第二级)。记录以 base64url 放在 `data:` 中,`id:`
|
||||
WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20 秒,45 秒收不到任何消息即判定死链。重连使用指数退避(1 秒到 30 秒)加抖动,并携带 `from=last_seq+1`。
|
||||
|
||||
### `GET /api/v1/channels/{id}/items/{item_id}/frames?from=<index>`
|
||||
拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载;`from>0` 只有同一下载方(登录账号按用户,访客按终端)此前对同一条目成功启动过 `from=0` 才能续传,否则返回 `410 gone`。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
|
||||
拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载;`from>0` 只有同一下载方(按用户)此前对同一条目成功启动过 `from=0` 才能续传,否则返回 `410 gone`。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
|
||||
|
||||
### `DELETE /api/v1/channels/{id}/items/{item_id}`
|
||||
按 `delete_policy` 授权后删除条目,并在日志中追加 tombstone。已登记签名设备还必须提交对应 `chat.delete`/`file.delete` 事件头;服务端验证其绑定的设备证书、权限和原对象帧 manifest 后保存删除签名,浏览器验签后才应用 tombstone。
|
||||
|
||||
### 访客
|
||||
- `GET /api/v1/channels/{id}/guests`:所有者查看未登录访客的终端(`endpoint_hash`、`joined_at`)。账号成员按用户管理(`/members`、`/invitations`),其设备不对外列出。
|
||||
- `DELETE /api/v1/channels/{id}/guests/{endpoint_hash}`:所有者移除该访客终端并禁止它再加入,其活动订阅随后被拒绝(`not_joined`);对账号终端返回 `404`。
|
||||
- 已移除:`PUT /channels/{id}/seats/me`、`PUT /channels/{id}/seats/{endpoint_hash}/label`、`GET/DELETE /channels/{id}/seats`。设备名只在账号设备管理里存在(`/accounts/devices`)。
|
||||
### 已移除的接口
|
||||
- v8:`GET /channels/{id}/guests`、`DELETE /channels/{id}/guests/{endpoint_hash}`(频道不再有未登录访客;成员按用户管理:`/members`、`/invitations`)。
|
||||
- v7:`PUT /channels/{id}/seats/me`、`PUT /channels/{id}/seats/{endpoint_hash}/label`、`GET/DELETE /channels/{id}/seats`。设备名只在账号设备管理里存在(`/accounts/devices`)。
|
||||
|
||||
## 4. 便捷分享接口
|
||||
|
||||
@@ -210,8 +209,9 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
|
||||
|---|---|---|
|
||||
| `bad_request` | 400 | 封装格式或参数错误 |
|
||||
| `unauthorized` | 401 | 令牌缺失或错误 |
|
||||
| `account_required` | 401 | 需要账号会话:频道与空间只接受已登录账号,账号会话缺失或已失效 |
|
||||
| `forbidden` | 403 | 角色不够 |
|
||||
| `not_joined` | 403 | 访客终端未加入或已被所有者移除,或账号不是该频道成员 |
|
||||
| `not_joined` | 403 | 账号不是该频道成员(未加入或已被所有者移除) |
|
||||
| `visitors_full` | 403 | 便捷分享的访客名额已满,且这个终端未登记 |
|
||||
| `sealed` | 409 | 便捷分享已封存,不能再修改;封存后再用所有者令牌发请求也是这个错误 |
|
||||
| `members_full` | 403 | 成员人数已满 |
|
||||
|
||||
@@ -77,7 +77,7 @@ SQLite 存元数据和小帧,磁盘存大帧。
|
||||
| `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`, `joined_at`;PK(`channel_id`, `endpoint_hash`)。账号成员只在 `channel_members`,不写此表(旧库里的 `label` 列不再使用) |
|
||||
| `channel_members` | 频道成员(账号):`channel_id`, `username`, `role`, `joined_at`。v8 起不再有访客席位,旧库里的 `seats` 表在启动时丢弃 |
|
||||
| `share_visitors` | `share_id`, `endpoint_hash`, `joined_at`;PK(`share_id`, `endpoint_hash`),创建者终端不写入此表 |
|
||||
| `items` | `channel_id`, `item_id`, `author_hash`, `flags`, `max_downloads`, `downloads`, `frame_count`, `max_index`, `final_index`, `bytes`, `created_at`, `expires_at`, `close_deadline`, `label`;PK(`channel_id`, `item_id`)。完整与否由 `final_index` 和 `frame_count` 推出,是否进入宽限期由 `close_deadline` 表示 |
|
||||
| `share_items` | 分享条目的帧状态和下载次数,独立于频道条目 |
|
||||
@@ -114,8 +114,8 @@ SQLite 存元数据和小帧,磁盘存大帧。
|
||||
- 发送队列落在 IndexedDB,恢复网络后自动补发,依赖协议的幂等追加。
|
||||
- 条目预览在沙箱 iframe 中渲染;raw 内容只当文本展示或下载。
|
||||
- 下载大文件优先使用 OPFS 流式写入临时文件,认证完成后提供文件;不支持 OPFS 时回退 Blob,限制 64 MiB。
|
||||
- 首页先注册/登录;公开频道/分享链接可按规则由访客访问。创建与管理在服务端绑定账号,成员人数按账号去重,设备只是账号的属性(账号设备与登录 session 在 `/accounts/devices` 管理);访客是未登录终端,单独登记。所有写入都检查账号会话或访客终端、频道角色、成员名额和能力权限。
|
||||
- IndexedDB 按账号与访客分库;切换时中止后台订阅并清空内存。保险库同步私钥、资源凭据和移除墓碑;设备 S、设备标签、草稿内容、缓存、原始文件和 outbox 保留本机。新设备生成独立 S 后重新加入。
|
||||
- 首页先注册/登录;频道链接只对登录账号有效(未登录先登录),便捷分享链接任何人可读。创建与管理在服务端绑定账号,成员人数按账号去重,设备只是账号的属性(账号设备与登录 session 在 `/accounts/devices` 管理)。所有频道写入都检查账号会话、频道角色、成员名额和能力权限。
|
||||
- IndexedDB 按账号分库;切换时中止后台订阅并清空内存。保险库同步私钥、资源凭据和移除墓碑;设备 S、设备标签、草稿内容、缓存、原始文件和 outbox 保留本机。新设备生成独立 S 后重新加入。
|
||||
- 空间统一管理成员、权限与密钥,后端管理频道、分组和目录。资源日志仅保存内容及资源元数据;CONTROL 资源元数据记录随空间删除。
|
||||
|
||||
## 7. 阶段划分
|
||||
@@ -124,7 +124,7 @@ SQLite 存元数据和小帧,磁盘存大帧。
|
||||
|---|---|---|
|
||||
| 0 | 文档、`test-vectors/` | 向量由独立脚本生成并人工复核 |
|
||||
| 1 | `@webdrop/core`:密钥派生、封装、加解密、padding、条目装配 | **已完成**:Vitest 通过全部向量和负例,并对着真实服务端端到端通过 |
|
||||
| 2 | 服务端(AOT):频道与便捷分享、帧存储、频道日志、WebSocket/SSE/长轮询、成员与访客、在线状态、访客名额、清理 | xUnit 通过;目标平台 AOT 发布并冒烟通过 |
|
||||
| 2 | 服务端(AOT):频道与便捷分享、帧存储、频道日志、WebSocket/SSE/长轮询、成员、在线状态、便捷分享访客名额、清理 | xUnit 通过;目标平台 AOT 发布并冒烟通过 |
|
||||
| 3 | 前端:创建频道、文本和 JSON 实时同步、断线重连、发送队列、「我的频道」 | Playwright:双浏览器内容一致,断网恢复后无丢失无重复,抓包无明文和 K |
|
||||
| 4 | 文件:流式加解密、GATED 拉取、下载计数、预览、粘贴和拖拽 | 100 MB 文件往返一致,下载计数正确 |
|
||||
| 5 | GB 级文件:并行上传和断点续传 | 中途断网后续传成功,内存占用平稳 |
|
||||
|
||||
+29
-66
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: WebDrop API
|
||||
version: 7.0.0
|
||||
version: 8.0.0
|
||||
servers:
|
||||
- url: /api/v1
|
||||
|
||||
@@ -9,7 +9,6 @@ tags:
|
||||
- name: server
|
||||
- name: channel
|
||||
- name: join
|
||||
- name: guest
|
||||
- name: frame
|
||||
- name: item
|
||||
- name: share
|
||||
@@ -94,7 +93,7 @@ paths:
|
||||
required: false
|
||||
schema: { type: string }
|
||||
- $ref: '#/components/parameters/Role'
|
||||
- $ref: '#/components/parameters/Endpoint'
|
||||
- $ref: '#/components/parameters/Account'
|
||||
- name: Authorization
|
||||
in: header
|
||||
schema: { type: string }
|
||||
@@ -123,7 +122,7 @@ paths:
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- $ref: '#/components/parameters/RoleWriter'
|
||||
- $ref: '#/components/parameters/Endpoint'
|
||||
- $ref: '#/components/parameters/Account'
|
||||
- name: Authorization
|
||||
in: header
|
||||
schema: { type: string }
|
||||
@@ -161,7 +160,7 @@ paths:
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- $ref: '#/components/parameters/RoleWriter'
|
||||
- $ref: '#/components/parameters/Endpoint'
|
||||
- $ref: '#/components/parameters/Account'
|
||||
- name: Authorization
|
||||
in: header
|
||||
schema: { type: string }
|
||||
@@ -199,7 +198,7 @@ paths:
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- $ref: '#/components/parameters/RoleWriter'
|
||||
- $ref: '#/components/parameters/Endpoint'
|
||||
- $ref: '#/components/parameters/Account'
|
||||
- name: Authorization
|
||||
in: header
|
||||
schema: { type: string }
|
||||
@@ -233,7 +232,7 @@ paths:
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- $ref: '#/components/parameters/RoleWriter'
|
||||
- $ref: '#/components/parameters/Endpoint'
|
||||
- $ref: '#/components/parameters/Account'
|
||||
- name: Authorization
|
||||
in: header
|
||||
schema: { type: string }
|
||||
@@ -271,7 +270,7 @@ paths:
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- $ref: '#/components/parameters/RoleWriter'
|
||||
- $ref: '#/components/parameters/Endpoint'
|
||||
- $ref: '#/components/parameters/Account'
|
||||
- name: Authorization
|
||||
in: header
|
||||
schema: { type: string }
|
||||
@@ -305,7 +304,7 @@ paths:
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- $ref: '#/components/parameters/RoleWriter'
|
||||
- $ref: '#/components/parameters/Endpoint'
|
||||
- $ref: '#/components/parameters/Account'
|
||||
- name: Authorization
|
||||
in: header
|
||||
schema: { type: string }
|
||||
@@ -343,7 +342,7 @@ paths:
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- $ref: '#/components/parameters/RoleWriter'
|
||||
- $ref: '#/components/parameters/Endpoint'
|
||||
- $ref: '#/components/parameters/Account'
|
||||
- name: Authorization
|
||||
in: header
|
||||
schema: { type: string }
|
||||
@@ -377,7 +376,7 @@ paths:
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- $ref: '#/components/parameters/RoleWriter'
|
||||
- $ref: '#/components/parameters/Endpoint'
|
||||
- $ref: '#/components/parameters/Account'
|
||||
- name: Authorization
|
||||
in: header
|
||||
schema: { type: string }
|
||||
@@ -603,7 +602,7 @@ paths:
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
- { $ref: '#/components/parameters/Account' }
|
||||
responses:
|
||||
'200':
|
||||
description: 成员列表
|
||||
@@ -679,7 +678,7 @@ paths:
|
||||
创建者的账号自动成为该频道 owner,其终端自动入席。
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/Captcha' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
- { $ref: '#/components/parameters/Account' }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -703,7 +702,7 @@ paths:
|
||||
get:
|
||||
tags: [channel]
|
||||
summary: 读取频道信息(不占成员名额)
|
||||
description: 任意角色可调用。`members_used` 表示唯一账号成员数加匿名访客终端数,不是设备数。
|
||||
description: 需要账号会话。`members_used` 表示唯一账号成员数,不是设备数。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
@@ -760,13 +759,12 @@ paths:
|
||||
tags: [join]
|
||||
summary: 加入频道
|
||||
description: |
|
||||
幂等。登录账号只登记成员关系:同一账号的多台设备不增加人数,也不登记终端,此后仅凭账号会话鉴权(`X-WD-Endpoint` 可省略)。
|
||||
未登录访客登记一个终端(`X-WD-Endpoint` 必填),每个终端单独计一个成员名额。成员名额已满返回 403 `members_full`。
|
||||
读取频道信息和 owner 管理接口不占名额;访客访问频道内容前必须已加入,否则 403 `not_joined`。
|
||||
幂等。必须带账号会话(没有则 401 `account_required`)。只登记成员关系:同一账号的多台设备不增加人数,此后仅凭账号会话鉴权。
|
||||
成员名额已满返回 403 `members_full`。读取频道信息和 owner 管理接口不占名额;访问频道内容前必须已加入,否则 403 `not_joined`。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
- { $ref: '#/components/parameters/Account' }
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
@@ -785,44 +783,6 @@ paths:
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'404': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/channels/{id}/guests:
|
||||
get:
|
||||
tags: [guest]
|
||||
summary: 查看访客(所有者)
|
||||
description: 只列未登录访客的终端。账号成员按用户管理(`/members`、`/invitations`),其设备不对外列出。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/RoleOwner' }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Guest' }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/channels/{id}/guests/{endpointHash}:
|
||||
delete:
|
||||
tags: [guest]
|
||||
summary: 移除访客(所有者)
|
||||
description: 移除该访客终端并禁止它再加入;被移除终端后续请求返回 `not_joined`。账号成员改用 `DELETE /invitations/{username}`。
|
||||
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' }
|
||||
responses:
|
||||
'204': { description: 已移除 }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
'404': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/channels/{id}/frames:
|
||||
post:
|
||||
tags: [frame]
|
||||
@@ -835,7 +795,7 @@ paths:
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/RoleWriter' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
- { $ref: '#/components/parameters/Account' }
|
||||
- name: X-WD-Max-Downloads
|
||||
in: header
|
||||
required: false
|
||||
@@ -875,7 +835,7 @@ paths:
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
- { $ref: '#/components/parameters/Account' }
|
||||
- name: from
|
||||
in: query
|
||||
required: false
|
||||
@@ -918,7 +878,7 @@ paths:
|
||||
|
||||
**服务端 → 客户端**
|
||||
- 二进制消息:一条日志记录(`seq ‖ kind ‖ body`)。
|
||||
- 文本消息(JSON):`{"t":"hb","seq":N}` 心跳(每 20 秒);`{"t":"settings"}` 设置变更;`{"t":"members"}` 成员/访客变更。
|
||||
- 文本消息(JSON):`{"t":"hb","seq":N}` 心跳(每 20 秒);`{"t":"settings"}` 设置变更;`{"t":"members"}` 成员变更。
|
||||
|
||||
客户端 45 秒收不到任何消息应判定死链,以指数退避(1~30 秒)加抖动重连,并带 `from=last_seq+1`。
|
||||
上传不走 WebSocket,仍用 `POST /frames`。
|
||||
@@ -950,7 +910,7 @@ paths:
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
- { $ref: '#/components/parameters/Account' }
|
||||
- name: from
|
||||
in: query
|
||||
required: false
|
||||
@@ -981,7 +941,7 @@ paths:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/ItemId' }
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
- { $ref: '#/components/parameters/Account' }
|
||||
- name: from
|
||||
in: query
|
||||
required: false
|
||||
@@ -1009,7 +969,7 @@ paths:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/ItemId' }
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
- { $ref: '#/components/parameters/Account' }
|
||||
responses:
|
||||
'204': { description: 已删除 }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
@@ -1243,6 +1203,12 @@ components:
|
||||
required: true
|
||||
description: 条目 id,16 字节 base64url
|
||||
schema: { $ref: '#/components/schemas/Base64Url' }
|
||||
Account:
|
||||
name: X-WD-Account
|
||||
in: header
|
||||
required: true
|
||||
description: 账号会话令牌。频道与空间只接受已登录账号,缺少或失效返回 401 `account_required`。
|
||||
schema: { type: string }
|
||||
Role:
|
||||
name: X-WD-Role
|
||||
in: header
|
||||
@@ -1263,7 +1229,7 @@ components:
|
||||
name: X-WD-Endpoint
|
||||
in: header
|
||||
required: false
|
||||
description: 终端密钥 `S` 的 base64url,仅存本地。只有未登录访客必填;登录账号以 `X-WD-Account` 会话鉴权,可省略。
|
||||
description: 终端密钥 `S` 的 base64url,仅存本地。只用于便捷分享(未登录的创建者与读者);频道与空间使用 `X-WD-Account` 会话,不需要它。
|
||||
schema: { $ref: '#/components/schemas/Base64Url' }
|
||||
ShareEndpoint:
|
||||
name: X-WD-Endpoint
|
||||
@@ -1410,8 +1376,5 @@ components:
|
||||
JoinResult:
|
||||
$ref: '../contracts/api.schemas.json#/$defs/JoinResult'
|
||||
|
||||
Guest:
|
||||
$ref: '../contracts/api.schemas.json#/$defs/GuestInfo'
|
||||
|
||||
AppendResult:
|
||||
$ref: '../contracts/api.schemas.json#/$defs/AppendResult'
|
||||
+6
-7
@@ -25,7 +25,7 @@
|
||||
| `K` | 32 B | 空间/临时频道访问凭据;新工作区内容加密不使用它,可选能力链接使用 fragment |
|
||||
| `A` | 32 B | 管理密钥,独立随机,只存在于所有者链接 |
|
||||
| `W` | 32 B | 写密钥,仅 raw 模式且成员可上传时 |
|
||||
| `S` | 32 B | 访客终端密钥,每个频道每个访客终端各生成一个,仅存本地;登录账号不需要 |
|
||||
| `S` | 32 B | 便捷分享的终端密钥(创建者与读者),仅存本地;频道与空间不使用,只凭账号会话鉴权 |
|
||||
|
||||
## 2. 模式与链接
|
||||
|
||||
@@ -161,7 +161,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
**推送规则**:
|
||||
- 非 GATED 条目以 frame 记录推送给订阅者。服务端强制:**非 GATED 条目必须是单帧且带 FINAL**(`frame_index = 0`)。
|
||||
- GATED 条目只推送一条 announce,字节通过条目拉取接口取得(见 api.md),`from=0` 的拉取计入下载次数。
|
||||
- `from>0` 只有在同一下载方(登录账号按用户,访客按终端)此前已对同一条目成功发起 `from=0` 后才是有效续传;服务端按资源、条目和下载方绑定这份续传许可,任意跳到中间帧不能绕过下载次数。
|
||||
- `from>0` 只有在同一下载方(频道按用户)此前已对同一条目成功发起 `from=0` 后才是有效续传;服务端按资源、条目和下载方绑定这份续传许可,任意跳到中间帧不能绕过下载次数。
|
||||
- 因此客户端上传**多帧条目(文件)或带下载上限的条目时,必须设置 GATED**。
|
||||
- 同一条目的所有帧,SNAPSHOT 与 GATED 标志必须一致;FINAL 不能早于已存在的更大序号;已有 FINAL 之后不能再追加更大序号的帧。GATED 条目的帧允许乱序到达(并行上传),条目拉取只返回从起点开始连续的帧。
|
||||
|
||||
@@ -178,10 +178,9 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
## 10. 用户、设备与成员人数
|
||||
|
||||
- **成员是用户(账号)**。设备只是账号的属性:同一账号可在任意多台设备登录,只占一个成员名额;账号成员的请求凭有效的账号 session 鉴权,不再需要终端密钥,也不登记终端。
|
||||
- **访客**是未登录的终端:本地为每个频道生成随机 `S`,请求中带 `S`,服务端保存 `SHA-256(S)` 作为访客标识,并通过显式的加入请求(`POST .../join`)登记。未加入的访客访问频道返回 `not_joined`;所有者可以列出(`GET .../guests`)并移除(`DELETE .../guests/{endpoint_hash}`)访客。
|
||||
- 频道设置字段 `max_members`(v7 前叫 `seats`)是**最大成员人数**。owner 和每个唯一账号各计一人,同一账号的额外设备不增加人数;访客没有可跨设备识别的账号身份,每个访客终端按一个访客成员计入上限,因此访客在多台设备加入会占多个名额。成员人数达到上限时,新的成员加入返回 `members_full`。若希望严格按自然人去重,应要求加入者登录账号。
|
||||
- 不同频道使用不同的 `S`(仅访客),避免服务端把同一访客终端关联起来。
|
||||
- 受限下载(GATED)的计数单位是「下载方」:登录账号按用户,访客按终端;同一下载方对同一条目重复发起 `from=0` 才算续传。
|
||||
- **频道强制登录**(v8):没有账号会话的请求一律返回 `account_required`,不再有未登录访客(没有 `allow_guests`、`/guests`、访客终端席位)。持链接的人先登录,再通过显式的加入请求(`POST .../join`)成为成员;未加入的账号访问频道内容返回 `not_joined`;所有者通过撤销邀请移除成员。便捷分享不受影响。
|
||||
- 频道设置字段 `max_members`(v7 前叫 `seats`)是**最大成员人数**。owner 和每个唯一账号各计一人,同一账号的额外设备不增加人数。成员人数达到上限时,新的成员加入返回 `members_full`。
|
||||
- 受限下载(GATED)的计数单位是「下载方」:按用户;同一下载方对同一条目重复发起 `from=0` 才算续传。
|
||||
- 在线状态由服务端按账号统计(`members` 接口的 `online`、`devices`、`online_devices`),只来自内存里的订阅登记,不写库。
|
||||
- **已移除**:每终端的加密设备名(`K_label`、AAD `WD1L`、`PUT .../seats/me` 与 `.../seats/{hash}/label`)和设备席位清单。设备名只在账号设备管理里存在(`/accounts/devices`)。`K_label` 的派生仍保留在 §3,为已发布的测试向量保持稳定,但不再被使用。
|
||||
|
||||
@@ -245,7 +244,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
| 模式 | enc 或 raw | **只有 enc** |
|
||||
| 谁能写 | 所有者和成员 | 只有创建者,且只在草稿阶段 |
|
||||
| 寿命 | 长期(TTL 滚动续期) | 短,封存后到期即清 |
|
||||
| 访客 | 作为访客成员计入人数上限 | 只读访客,按终端计数,不展示人数 |
|
||||
| 谁能读 | 已登录的成员(账号) | 持链接的任何人,无需账号;访客按终端计数,不展示人数 |
|
||||
|
||||
分享与频道**共用同一个 16 字节 id 空间**,服务端保证全局唯一,所以同一个 id 不可能既是频道又是分享。分享 id 在频道接口上返回 `not_found`,反之亦然。
|
||||
|
||||
|
||||
@@ -54,14 +54,14 @@
|
||||
| 模式降级(enc 换成 raw) | 链接带 `k` 就只接受 enc 且认证通过的帧,不匹配直接拒绝 |
|
||||
| 重放旧帧 | 同一条目内 (item_id, frame_index) 唯一,服务端幂等,客户端按条目去重 |
|
||||
| 服务端隐瞒或删除条目 | **无法防止**(可用性),见 §6 |
|
||||
| 链接泄露 | 链接即能力。成员人数上限限制账号成员和访客终端加入数,所有者可撤销成员、移除访客和删除频道 |
|
||||
| 链接泄露 | 链接即能力。成员人数上限限制账号成员数,频道只接受登录账号,所有者可撤销成员和删除频道 |
|
||||
| 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(访客为终端登记)、成员人数;加密选项不会绕开权限 |
|
||||
| 账号混用/设备身份混用 | 按账号隔离 IndexedDB,切换时中止后台任务;保险库不包含设备 S,每台设备单独生成 |
|
||||
| 绕过频道加入规则 | 服务端检查 owner/member 角色、邀请、密码、账号 session(没有则一律拒绝)、成员人数;加密选项不会绕开权限 |
|
||||
| 匿名滥用与 DoS | 创建时的 Cap 验证码(工作量证明由 Cap 提供)、限流、频道配额、条目数和字节数上限 |
|
||||
|
||||
## 5. 前端信任根(残余风险)
|
||||
|
||||
+4
-4
@@ -113,14 +113,14 @@ WebDrop 是**暂存区**,频道工作区:聊天按 thread 展示,文件按 thr
|
||||
| 快速分享(输入文本、拖入、粘贴、选择、相册、拍照) | 先在首页编辑临时文本并收集文件;点「创建临时分享」后建立加密草稿并带入内容;点「完成分享」后生成链接与二维码 |
|
||||
| 新建频道 | 登录后创建,自动成为该频道所有者;可选择是否端到端加密,默认启用;成员人数上限、TTL、受限下载次数和上传权限仍用同一频道模型 |
|
||||
| 加入频道 | 扫码或粘贴链接 → 加入预览 → 加入 |
|
||||
| 我的频道 | 点一行进入;垃圾桶从当前账号/访客资源清单移除,**不会删除频道**,需二次确认;账号清单移除会同步到其他设备 |
|
||||
| 我的频道 | 点一行进入;垃圾桶从当前账号资源清单移除,**不会删除频道**,需二次确认;账号清单移除会同步到其他设备 |
|
||||
| 账号与同步 | 用户名密码注册或登录;本地频道/分享凭据在 Worker 加密后同步,另一台设备登录后恢复;退出时撤销会话 |
|
||||
|
||||
登录/注册页是居中的单列卡片(标题与标语在上,卡片在下);创建账号需要再输入一次密码确认,两次不一致时就地提示并禁用提交。账号设置是左侧导航 + 右侧单页(个人资料 / 我的设备 / 安全 / 空间邀请 / 同步与登录;手机上导航变为顶部横向页签),结果统一用 toast 反馈,设备重命名就地编辑,移除设备与退出登录需确认。
|
||||
|
||||
账号密码不提供找回流程。忘记密码会导致账号保险库无法解密;界面需在注册区域明确提示。本机记住登录通过 IndexedDB 保存 session 和不可导出的保险库 CryptoKey,刷新后恢复身份;退出或设备被撤销后清除。
|
||||
|
||||
未登录访问首页时先显示注册/登录页;收到的频道或分享链接保持访客入口。频道加入页按当前规则显示密码输入或登录提示。右上角频道设置集中管理加入方式、陌生人开关、频道密码、成员角色、邀请与撤销,不另设权限入口。
|
||||
未登录访问首页时先显示注册/登录页;收到的分享链接任何人可读;收到的频道链接要求登录,未登录先显示登录页,登录后继续加入。频道加入页按当前规则显示密码输入。右上角频道设置集中管理加入方式(登录后持链接加入 / 仅受邀账号)、频道密码、成员角色、邀请与撤销,不另设权限入口。
|
||||
|
||||
| 路由 | 页面 |
|
||||
|---|---|
|
||||
@@ -234,8 +234,8 @@ Share 是独立的只读发布资源,不创建频道成员或 threads。创建
|
||||
|
||||
### 7.5 频道文件预览与下载限制
|
||||
|
||||
- 新上传文件默认允许单帧内容直接随频道同步。加入频道的访客可直接看到文件名、类型、大小;PNG 等图片和 TXT 等文本可直接预览,不消耗下载次数。文件名与类型仍只存在于加密帧中。
|
||||
- 超出单帧容量的文件保持 GATED,列表不自动读取内容。访客主动打开预览时才取回文件,并消耗一次下载次数;取回后显示解密出的文件名、类型和大小。EXE 等不支持内联预览的类型仍提供下载。
|
||||
- 新上传文件默认允许单帧内容直接随频道同步。加入频道的成员可直接看到文件名、类型、大小;PNG 等图片和 TXT 等文本可直接预览,不消耗下载次数。文件名与类型仍只存在于加密帧中。
|
||||
- 超出单帧容量的文件保持 GATED,列表不自动读取内容。成员主动打开预览时才取回文件,并消耗一次下载次数;取回后显示解密出的文件名、类型和大小。EXE 等不支持内联预览的类型仍提供下载。
|
||||
- 上传者可关闭直接预览并为文件设置下载次数上限。
|
||||
- 设备标签优先显示浏览器提供的机型和系统版本,例如「Pixel 9 (Android 16) · A1B2C3D4」「Windows 11 · 1234ABCD」。浏览器没有暴露具体机型时显示能识别的平台名称;保留端点短标识以区分同型号设备。
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ const { complete, nextIndex, meta } = await result;
|
||||
|
||||
## 必须注意的几点
|
||||
|
||||
**1. 访客终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这个访客终端在这个频道里」,服务端按它登记访客以便认证和移除;登录账号以账号会话鉴权,不需要它,同一账号的多台设备也不重复占名额。访客不持久化的话,每次打开链接都会登记一个新访客终端。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。
|
||||
**1. 终端密钥只用于便捷分享。** `session.endpointSecret`(32 字节)标识「这个终端」,便捷分享的创建者与读者靠它登记和续传;频道与空间只凭账号会话鉴权,不使用它。分享读者不持久化的话,每次打开链接都会登记一个新访客终端,用 IndexedDB 保存。
|
||||
|
||||
**2. Worker。** `ChannelSession` 实例不能跨线程传递(它持有 `CryptoKey` 和闭包)。在 Worker 里用链接和 `endpointSecret` 重建它,主线程只传链接、帧字节和事件。
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ export type AuthRole = 'read' | 'write' | 'owner';
|
||||
/** 频道设置,字段名与 API 一致(snake_case)。 */
|
||||
export interface ChannelSettings {
|
||||
join_policy?: 'open' | 'invite';
|
||||
allow_guests?: boolean;
|
||||
name?: string;
|
||||
max_members?: number;
|
||||
members_can_upload?: boolean;
|
||||
|
||||
@@ -45,7 +45,8 @@ 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 } : {}) };
|
||||
// 频道只接受登录账号:测试里所有角色共用同一个账号,权限差异由角色令牌体现
|
||||
return { ...session.authHeaders(role), 'X-WD-Account': accountToken };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -145,7 +146,7 @@ function subscribe(session: ChannelSession, role: AuthRole, until: (events: Item
|
||||
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(/=+$/, '')}`);
|
||||
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';
|
||||
@@ -416,7 +417,7 @@ suite('端到端(真实服务端)', () => {
|
||||
const member = await ChannelSession.open(owner.link(ORIGIN));
|
||||
await joinChannel(member, 'read');
|
||||
|
||||
const res = await fetch(api(`/channels/${owner.id}/guests`), { headers: { ...headersFor(member, 'read') } });
|
||||
const res = await fetch(api(`/channels/${owner.id}/settings`), { method: 'PATCH', headers: { ...headersFor(member, 'read'), 'Content-Type': 'application/json' }, body: '{}' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(() => headersFor(member, 'owner')).toThrow();
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ namespace WebDrop.Api;
|
||||
public static class ApiContract
|
||||
{
|
||||
// v5: 频道容量按账号成员计;v7: seats 改名 max_members/members_used/members_full,
|
||||
// 去掉设备名与设备清单接口,访客改走 /guests。设备只是账号的属性。
|
||||
// 去掉设备名与设备清单接口。设备只是账号的属性。
|
||||
// v8: 频道强制登录,去掉未登录访客(allow_guests、/guests、终端席位);便捷分享不受影响。
|
||||
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
|
||||
public const string Version = "7.0.0";
|
||||
public const string Version = "8.0.0";
|
||||
}
|
||||
@@ -235,7 +235,6 @@ public sealed class WorkspaceGroupCreatedBody
|
||||
[JsonSerializable(typeof(CreateChannelRequest))]
|
||||
[JsonSerializable(typeof(ChannelSettingsPatch))]
|
||||
[JsonSerializable(typeof(JoinResult))]
|
||||
[JsonSerializable(typeof(List<GuestInfo>))]
|
||||
[JsonSerializable(typeof(AppendResult))]
|
||||
[JsonSerializable(typeof(AccountRegisterRequest))]
|
||||
[JsonSerializable(typeof(AccountDeviceSigningRequest))]
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed class ChannelHub : IChannelEvents
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<Id16, Signal> _signals = [];
|
||||
|
||||
// 在线状态:频道 -> 账号 -> 账号设备 -> 活动订阅数。只记账号,访客没有可跨设备识别的身份,不参与。
|
||||
// 在线状态:频道 -> 账号 -> 账号设备 -> 活动订阅数。只记账号。
|
||||
private readonly Dictionary<Id16, Dictionary<string, Dictionary<string, int>>> _presence = [];
|
||||
|
||||
public Subscription Subscribe(Id16 id)
|
||||
@@ -26,9 +26,8 @@ public sealed class ChannelHub : IChannelEvents
|
||||
}
|
||||
|
||||
/// <summary>登记一个活动订阅,释放返回值即下线。只供成员接口读取,不向订阅流推送,以免改变流的线格式。</summary>
|
||||
public IDisposable Connected(Id16 id, string? account, string deviceKey)
|
||||
public IDisposable Connected(Id16 id, string account, string deviceKey)
|
||||
{
|
||||
if (account is null) return NoPresence.Instance;
|
||||
string endpoint = deviceKey;
|
||||
lock (_gate)
|
||||
{
|
||||
@@ -67,12 +66,6 @@ public sealed class ChannelHub : IChannelEvents
|
||||
public void Dispose() => Interlocked.Exchange(ref _release, null)?.Invoke();
|
||||
}
|
||||
|
||||
private sealed class NoPresence : IDisposable
|
||||
{
|
||||
public static readonly NoPresence Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
public void Advanced(Id16 channelId, long seq) => Publish(channelId, s => s.Seq = Math.Max(s.Seq, seq));
|
||||
public void SettingsChanged(Id16 channelId) => Publish(channelId, s => s.SettingsVersion++);
|
||||
public void MembersChanged(Id16 channelId) => Publish(channelId, s => s.MembersVersion++);
|
||||
|
||||
@@ -101,7 +101,6 @@ public static partial class Endpoints
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
accounts.RevokeDevice(username, id);
|
||||
svc.ReleaseAccountDevice(username, id);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ public static partial class Endpoints
|
||||
api.MapGet("/channels/{id}/members", (string id, HttpContext ctx, ChannelService svc, ChannelHub hub) =>
|
||||
{
|
||||
var channelId = ParseId(id);
|
||||
var members = svc.Members(channelId, RequestAuth.FromHeaders(ctx.Request, true)).Select(member =>
|
||||
var members = svc.Members(channelId, RequestAuth.FromHeaders(ctx.Request, false)).Select(member =>
|
||||
{
|
||||
var (online, onlineDevices) = hub.Presence(channelId, member.Username);
|
||||
// 设备是账号的属性:总数取账号的登录设备,在线数取该账号在本频道有订阅的设备
|
||||
|
||||
@@ -14,49 +14,49 @@ public static partial class Endpoints
|
||||
.Select(u => accounts.Profile(u!)).ToList(),
|
||||
};
|
||||
api.MapGet("/spaces/{id}/structure", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
Json(View(svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true))), ApiJson.Default.WorkspaceBody));
|
||||
Json(View(svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.WorkspaceBody));
|
||||
api.MapPost("/spaces/{id}/channels", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceChannelRequest, ct);
|
||||
var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.Name, req.GroupId);
|
||||
var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name, req.GroupId);
|
||||
return Json(new WorkspaceChannelCreatedBody { Workspace = View(created.State), Channel = created.Channel }, ApiJson.Default.WorkspaceChannelCreatedBody, 201);
|
||||
});
|
||||
api.MapPatch("/spaces/{id}/channels/{channelId}", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||
return Json(View(svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||
return Json(View(svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
api.MapPut("/spaces/{id}/channels/{channelId}/group", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceGroupRequest, ct);
|
||||
return Json(View(svc.GroupWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.GroupId)), ApiJson.Default.WorkspaceBody);
|
||||
return Json(View(svc.GroupWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.GroupId)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
api.MapPost("/spaces/{id}/groups", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||
var created = svc.CreateWorkspaceGroupResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.Name);
|
||||
var created = svc.CreateWorkspaceGroupResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Name);
|
||||
return Json(new WorkspaceGroupCreatedBody { Workspace = View(created.State), Group = created.Group }, ApiJson.Default.WorkspaceGroupCreatedBody, 201);
|
||||
});
|
||||
api.MapPatch("/spaces/{id}/groups/{groupId}", async (string id, string groupId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||
return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), groupId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||
return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct);
|
||||
return Json(View(svc.CreateWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.ThreadId, req.ParentId, req.Name)), ApiJson.Default.WorkspaceBody, 201);
|
||||
return Json(View(svc.CreateWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ThreadId, req.ParentId, req.Name)), ApiJson.Default.WorkspaceBody, 201);
|
||||
});
|
||||
api.MapPatch("/spaces/{id}/folders/{folderId}", async (string id, string folderId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
|
||||
return Json(View(svc.RenameWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), folderId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||
return Json(View(svc.RenameWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), folderId, req.Name)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
api.MapPut("/spaces/{id}/locations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceMoveRequest, ct);
|
||||
if (req.ItemIds is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
return Json(View(svc.MoveWorkspaceItems(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.ItemIds, req.ThreadId, req.FolderId)), ApiJson.Default.WorkspaceBody);
|
||||
return Json(View(svc.MoveWorkspaceItems(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.ItemIds, req.ThreadId, req.FolderId)), ApiJson.Default.WorkspaceBody);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,21 +12,21 @@ public static partial class Endpoints
|
||||
{
|
||||
private static void MapWorkspaceCrypto(RouteGroupBuilder api)
|
||||
{
|
||||
api.MapGet("/spaces/{id}/crypto", (string id, HttpContext ctx, ChannelService svc) => Json(svc.ReadCrypto(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true)), ApiJson.Default.CryptoState));
|
||||
api.MapGet("/spaces/{id}/crypto", (string id, HttpContext ctx, ChannelService svc) => Json(svc.ReadCrypto(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false)), ApiJson.Default.CryptoState));
|
||||
api.MapPut("/spaces/{id}/crypto/domains", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.DomainPublishRequest, ct);
|
||||
svc.PublishDomain(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req); return Results.NoContent();
|
||||
svc.PublishDomain(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req); return Results.NoContent();
|
||||
});
|
||||
api.MapPost("/spaces/{id}/crypto/objects", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.ObjectEnvelope, ct);
|
||||
return Json(svc.DeclareObject(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req), ApiJson.Default.ObjectEnvelope, 201);
|
||||
return Json(svc.DeclareObject(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req), ApiJson.Default.ObjectEnvelope, 201);
|
||||
});
|
||||
api.MapPatch("/spaces/{id}/channels/{channelId}/history", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.ChannelHistoryRequest, ct);
|
||||
svc.SetChannelHistory(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.ChatHistory, req.FilesHistory); return Results.NoContent();
|
||||
svc.SetChannelHistory(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), channelId, req.ChatHistory, req.FilesHistory); return Results.NoContent();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -63,10 +63,9 @@ public static partial class Endpoints
|
||||
{
|
||||
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, username, accounts.SessionDevice(ctx.Request.Headers["X-WD-Account"].ToString())), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created);
|
||||
return Json(svc.CreateChannel(req, username), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created);
|
||||
}).RequireRateLimiting(CreatePolicy);
|
||||
|
||||
api.MapGet("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
@@ -90,28 +89,17 @@ public static partial class Endpoints
|
||||
api.MapPost("/channels/{id}/join", 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.JoinResult);
|
||||
return Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), request.Password), ApiJson.Default.JoinResult);
|
||||
});
|
||||
|
||||
MapChannelAccess(api, accounts);
|
||||
|
||||
// 访客是未登录的终端;账号成员只按用户管理(members / invitations),设备不对外列出
|
||||
api.MapGet("/channels/{id}/guests", (string id, HttpContext ctx, ChannelService svc) =>
|
||||
Json([.. svc.ListGuests(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false))], ApiJson.Default.ListGuestInfo));
|
||||
|
||||
api.MapDelete("/channels/{id}/guests/{endpointHash}", (string id, string endpointHash, HttpContext ctx, ChannelService svc) =>
|
||||
{
|
||||
if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
svc.ReleaseGuest(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
// ───────── 帧与日志 ─────────
|
||||
|
||||
api.MapPost("/channels/{id}/frames", async (string id, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore, ServerLimits limits, CancellationToken ct) =>
|
||||
{
|
||||
var channelId = ParseId(id);
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
|
||||
long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString());
|
||||
|
||||
byte[] body = await ReadBody(ctx.Request, Envelope.HeaderSize + limits.MaxPayloadBytes, ct);
|
||||
@@ -123,7 +111,7 @@ public static partial class Endpoints
|
||||
api.MapGet("/channels/{id}/log", async (string id, HttpContext ctx, ChannelService svc, ChannelHub hub, CancellationToken ct) =>
|
||||
{
|
||||
var channelId = ParseId(id);
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
|
||||
long from = QueryLong(ctx.Request, "from", 0);
|
||||
int limit = (int)Math.Clamp(QueryLong(ctx.Request, "limit", 200), 1, 1000);
|
||||
var wait = TimeSpan.FromSeconds(Math.Clamp(QueryLong(ctx.Request, "wait", 0), 0, 25));
|
||||
@@ -171,7 +159,7 @@ public static partial class Endpoints
|
||||
api.MapGet("/channels/{id}/events", async (string id, HttpContext ctx, ChannelService svc, ChannelHub hub) =>
|
||||
{
|
||||
var channelId = ParseId(id);
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
|
||||
long from = QueryLong(ctx.Request, "from", -1);
|
||||
if (from < 0)
|
||||
from = long.TryParse(ctx.Request.Headers["Last-Event-ID"].ToString(), out long last) && last >= 0 ? last + 1 : 0;
|
||||
@@ -192,7 +180,7 @@ public static partial class Endpoints
|
||||
{
|
||||
var channelId = ParseId(id);
|
||||
var item = ParseId(itemId);
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
|
||||
long from = QueryLong(ctx.Request, "from", 0);
|
||||
if (from is < 0 or > uint.MaxValue) throw new WebDropException(ErrorCode.BadRequest);
|
||||
|
||||
@@ -220,7 +208,7 @@ public static partial class Endpoints
|
||||
api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) =>
|
||||
{
|
||||
var channelId = ParseId(id); var parsedItemId = ParseId(itemId);
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
|
||||
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false);
|
||||
var proof = ParseSignedDeleteEvent(ctx.Request, accountStore, svc, channelId, parsedItemId, creds);
|
||||
svc.DeleteItem(channelId, creds, parsedItemId, proof);
|
||||
return Results.NoContent();
|
||||
|
||||
@@ -31,7 +31,7 @@ public static class LogStreamer
|
||||
ILogSink? sink = null;
|
||||
IDisposable? presence = null;
|
||||
long cursor = from;
|
||||
var (settingsV, seatsV, _) = sub.State;
|
||||
var (settingsV, membersV, _) = sub.State;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -42,7 +42,7 @@ public static class LogStreamer
|
||||
var page = await svc.ReadLogAsync(id, creds, cursor, PageSize, ct).ConfigureAwait(false);
|
||||
sink ??= await open().ConfigureAwait(false);
|
||||
// 鉴权与首次读取都成功之后才算在线
|
||||
presence ??= hub.Connected(id, creds.AccountName, creds.AccountDeviceId ?? Convert.ToHexString(creds.EndpointHash));
|
||||
presence ??= hub.Connected(id, creds.AccountName!, creds.AccountDeviceId ?? "");
|
||||
|
||||
foreach (var record in page.Records)
|
||||
{
|
||||
@@ -50,15 +50,15 @@ public static class LogStreamer
|
||||
cursor = record.Seq + 1;
|
||||
}
|
||||
|
||||
var (settingsNow, seatsNow, deleted) = sub.State;
|
||||
var (settingsNow, membersNow, deleted) = sub.State;
|
||||
if (settingsNow != settingsV)
|
||||
{
|
||||
settingsV = settingsNow;
|
||||
await sink.SendControlAsync("""{"t":"settings"}""", ct).ConfigureAwait(false);
|
||||
}
|
||||
if (seatsNow != seatsV)
|
||||
if (membersNow != membersV)
|
||||
{
|
||||
seatsV = seatsNow;
|
||||
membersV = membersNow;
|
||||
await sink.SendControlAsync("""{"t":"members"}""", ct).ConfigureAwait(false);
|
||||
}
|
||||
if (deleted) throw new WebDropException(ErrorCode.NotFound);
|
||||
|
||||
@@ -35,7 +35,7 @@ public static class RequestAuth
|
||||
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 is null), accountName, deviceId, accountName is not null);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ public static class RequestAuth
|
||||
}
|
||||
var parsedRole = ParseRole(role);
|
||||
if (parsedRole == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: accountName is null), accountName, deviceId, accountName is not null);
|
||||
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: false), accountName, deviceId, accountName is not null);
|
||||
}
|
||||
|
||||
public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true);
|
||||
|
||||
@@ -106,17 +106,13 @@ public interface IResourceTx
|
||||
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);
|
||||
bool IsJoinDenied(Id16 id, string username);
|
||||
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);
|
||||
@@ -124,13 +120,8 @@ public interface IResourceTx
|
||||
void DeleteChannelRows(Id16 id);
|
||||
IReadOnlyList<Id16> ListExpiredChannels(long now, int limit);
|
||||
|
||||
// 席位
|
||||
Seat? GetSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash);
|
||||
int CountSeats(Id16 channelId);
|
||||
// 成员
|
||||
int CountMembers(Id16 channelId);
|
||||
void InsertSeat(Id16 channelId, Seat seat);
|
||||
bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash);
|
||||
IReadOnlyList<Seat> ListSeats(Id16 channelId);
|
||||
|
||||
// 条目
|
||||
Item? GetItem(Id16 channelId, Id16 itemId);
|
||||
|
||||
@@ -14,13 +14,12 @@ public sealed partial class ChannelService
|
||||
|
||||
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;
|
||||
string account = creds.AccountName ?? throw new WebDropException(ErrorCode.AccountRequired);
|
||||
if (account == channel.CreatorAccount) return;
|
||||
if (tx.IsJoinDenied(channel.Id, account)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (tx.HasInvitation(channel.Id, account)) return;
|
||||
if (tx.GetChannelMember(channel.Id, account) is not null) return;
|
||||
if (channel.Settings.JoinPolicy == 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);
|
||||
@@ -30,7 +29,7 @@ public sealed partial class ChannelService
|
||||
|
||||
public IReadOnlyList<ChannelInvitation> Invitations(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||
RequireOwner(principal);
|
||||
return tx.ListInvitations(id);
|
||||
});
|
||||
@@ -41,12 +40,11 @@ public sealed partial class ChannelService
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.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);
|
||||
var crypto = tx.GetWorkspaceCrypto(id);
|
||||
crypto.Access.RemoveAll(a => a.Username == username);
|
||||
@@ -61,26 +59,20 @@ public sealed partial class ChannelService
|
||||
|
||||
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);
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||
if (principal.Role != Role.Owner) _ = Authenticate(tx, id, creds, Membership.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.MembersChanged(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);
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||
RequireOwner(principal);
|
||||
tx.PutKeyPackage(id, principal.AccountName!, payload, clock.NowMs);
|
||||
return 0;
|
||||
|
||||
@@ -4,7 +4,7 @@ public sealed partial class ChannelService
|
||||
{
|
||||
public InvitationCryptoPlan InvitationPlan(Id16 id, Credentials creds, string username) => store.Read(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal);
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.None); RequireOwner(principal);
|
||||
if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict);
|
||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var channelId = workspace.DefaultChannelId ?? throw new WebDropException(ErrorCode.Conflict);
|
||||
@@ -43,7 +43,7 @@ public sealed partial class ChannelService
|
||||
if (payload is null || payload.Length is < 1 or > 16_384 || envelopes is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal);
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.None); RequireOwner(principal);
|
||||
if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict);
|
||||
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
|
||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
@@ -136,7 +136,7 @@ public sealed partial class ChannelService
|
||||
}
|
||||
public CryptoState ReadCrypto(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
var state = tx.GetWorkspaceCrypto(id);
|
||||
if (principal.Role == Role.Owner || principal.AccountName == space.CreatorAccount) return state;
|
||||
var access = state.Access.Where(a => a.Username == principal.AccountName).ToList();
|
||||
@@ -154,7 +154,7 @@ public sealed partial class ChannelService
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal);
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.Required); RequireOwner(principal);
|
||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var channel = workspace.Channels.Find(c => c.Id == req.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (!Enum.IsDefined(req.Domain) || req.Envelopes is null || req.Envelopes.Count != 1 || req.Epoch != 1) throw new WebDropException(ErrorCode.BadRequest);
|
||||
@@ -177,7 +177,7 @@ public sealed partial class ChannelService
|
||||
}
|
||||
public ObjectEnvelope DeclareObject(Id16 id, Credentials creds, ObjectEnvelope req) => store.Write(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
if (!ChannelPolicy.CanWrite(space, principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (!Id16.TryParse(req.ItemId, out _) || !Enum.IsDefined(req.Domain) || req.KeyEnvelope is null || req.KeyEnvelope.Length > 1024) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
@@ -202,7 +202,7 @@ public sealed partial class ChannelService
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal);
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.Required); RequireOwner(principal);
|
||||
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
channel.ChatHistory = chat; channel.FilesHistory = files; workspace.Revision++; tx.PutWorkspace(id, workspace); return 0;
|
||||
|
||||
@@ -19,9 +19,7 @@ public static class ChannelPolicy
|
||||
return channel.Settings.DeletePolicy switch
|
||||
{
|
||||
DeletePolicy.Anyone => true,
|
||||
DeletePolicy.Own => item.AuthorAccount is not null
|
||||
? item.AuthorAccount == p.AccountName
|
||||
: item.AuthorHash.AsSpan().SequenceEqual(p.EndpointHash),
|
||||
DeletePolicy.Own => item.AuthorAccount == p.AccountName,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@ public sealed partial class ChannelService(
|
||||
ServerLimits limits,
|
||||
IChannelEvents events)
|
||||
{
|
||||
private enum SeatRule { None, Required }
|
||||
/// <summary>Required:必须已是频道成员;None:只验证身份(加入、查看信息等)。</summary>
|
||||
private enum Membership { None, Required }
|
||||
|
||||
// ───────────────────────── 频道 ─────────────────────────
|
||||
|
||||
public ChannelInfo CreateChannel(CreateChannelRequest req, byte[] endpointHash, string creatorAccount, string? accountDeviceId = null)
|
||||
public ChannelInfo CreateChannel(CreateChannelRequest req, string creatorAccount)
|
||||
{
|
||||
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);
|
||||
@@ -66,7 +67,7 @@ public sealed partial class ChannelService(
|
||||
|
||||
public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
var (channel, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||
return ToInfo(channel, tx.CountMembers(id), principal);
|
||||
});
|
||||
|
||||
@@ -74,7 +75,7 @@ public sealed partial class ChannelService(
|
||||
{
|
||||
var info = store.Write(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
var (channel, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||
RequireOwner(principal);
|
||||
|
||||
var updated = patch.ApplyTo(channel.Settings);
|
||||
@@ -100,7 +101,7 @@ public sealed partial class ChannelService(
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
var (_, principal) = Authenticate(tx, id, creds, Membership.None);
|
||||
RequireOwner(principal);
|
||||
tx.DeleteChannelRows(id);
|
||||
return 0;
|
||||
@@ -111,62 +112,27 @@ public sealed partial class ChannelService(
|
||||
|
||||
// ───────────────────────── 席位 ─────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 加入频道。账号成员只登记成员关系(同一账号的多台设备不增加人数,也不登记终端);
|
||||
/// 未登录访客登记终端,每个终端按一个访客成员计入上限。
|
||||
/// </summary>
|
||||
/// <summary>加入频道。只有已登录的账号能加入:登记成员关系,同一账号的多台设备不增加人数。</summary>
|
||||
public JoinResult Join(Id16 id, Credentials creds, string? password = null)
|
||||
{
|
||||
var (joined, isNew) = store.Write(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
var (channel, _) = Authenticate(tx, id, creds, Membership.None);
|
||||
string account = creds.AccountName!;
|
||||
int membersUsed = tx.CountMembers(id);
|
||||
JoinResult Result(long joinedAt, int used) => new() { JoinedAt = joinedAt, MembersUsed = used, MembersMax = channel.Settings.MaxMembers };
|
||||
|
||||
if (creds.AccountName is not null)
|
||||
{
|
||||
if (tx.GetChannelMember(id, creds.AccountName) is { } existingMember) return (Result(existingMember.JoinedAt, membersUsed), false);
|
||||
CheckAdmission(tx, channel, creds, password);
|
||||
if (membersUsed >= channel.Settings.MaxMembers) throw new WebDropException(ErrorCode.MembersFull);
|
||||
long memberAt = clock.NowMs;
|
||||
tx.PutChannelMember(id, new ChannelMember(creds.AccountName, "member", memberAt));
|
||||
return (Result(memberAt, membersUsed + 1), true);
|
||||
}
|
||||
|
||||
if (tx.GetSeat(id, principal.EndpointHash) is { } existing) return (Result(existing.JoinedAt, membersUsed), false);
|
||||
if (tx.GetChannelMember(id, account) is { } existingMember) return (Result(existingMember.JoinedAt, membersUsed), false);
|
||||
CheckAdmission(tx, channel, creds, password);
|
||||
if (membersUsed >= channel.Settings.MaxMembers) throw new WebDropException(ErrorCode.MembersFull);
|
||||
long now = clock.NowMs;
|
||||
tx.InsertSeat(id, new Seat(principal.EndpointHash, now));
|
||||
return (Result(now, membersUsed + 1), true);
|
||||
long memberAt = clock.NowMs;
|
||||
tx.PutChannelMember(id, new ChannelMember(account, "member", memberAt));
|
||||
return (Result(memberAt, membersUsed + 1), true);
|
||||
});
|
||||
if (isNew) events.MembersChanged(id);
|
||||
return joined;
|
||||
}
|
||||
|
||||
/// <summary>所有者查看访客(未登录终端)。账号成员按用户管理,其设备不对外列出。</summary>
|
||||
public IReadOnlyList<GuestInfo> ListGuests(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
RequireOwner(principal);
|
||||
return (IReadOnlyList<GuestInfo>)tx.ListSeats(id).Where(s => s.AccountName is null)
|
||||
.Select(s => new GuestInfo { EndpointHash = Codec.Encode(s.EndpointHash), JoinedAt = s.JoinedAt }).ToList();
|
||||
});
|
||||
|
||||
/// <summary>所有者移除一个访客终端并禁止它再加入。账号成员改用撤销邀请(RevokeInvitation)。</summary>
|
||||
public void ReleaseGuest(Id16 id, Credentials creds, byte[] endpointHash)
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
RequireOwner(principal);
|
||||
if (tx.GetSeat(id, endpointHash) is not { AccountName: null } || !tx.DeleteSeat(id, endpointHash)) throw new WebDropException(ErrorCode.NotFound);
|
||||
tx.DenyEndpoint(id, endpointHash);
|
||||
return 0;
|
||||
});
|
||||
events.MembersChanged(id);
|
||||
}
|
||||
|
||||
// ───────────────────────── 追加帧 ─────────────────────────
|
||||
|
||||
public async Task<AppendResult> AppendFrameAsync(
|
||||
@@ -182,7 +148,7 @@ public sealed partial class ChannelService(
|
||||
// 1. 预检:鉴权并处理幂等重发,避免为重复帧白写 blob
|
||||
var existing = store.Read(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
|
||||
var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required);
|
||||
CheckWritable(channel, principal, env);
|
||||
CheckObjectWrite(tx, channel, principal, env);
|
||||
return tx.GetFrame(channelId, env.ItemId, env.FrameIndex);
|
||||
@@ -228,7 +194,7 @@ public sealed partial class ChannelService(
|
||||
IResourceTx tx, Id16 channelId, Credentials creds, Envelope env, ReadOnlyMemory<byte> body,
|
||||
byte[] digest, string? blobKey, long? maxDownloads, SignedContentEventProof? signedEvent)
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
|
||||
var (channel, principal) = Authenticate(tx, channelId, creds, Membership.Required);
|
||||
CheckWritable(channel, principal, env);
|
||||
CheckObjectWrite(tx, channel, principal, env);
|
||||
|
||||
@@ -291,7 +257,7 @@ public sealed partial class ChannelService(
|
||||
if (item is not null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
long max = env.IsGated ? maxDownloads ?? channel.Settings.FileMaxDownloads : 0;
|
||||
if (env.IsGated && !ServerSettingChoices.IsFileMaxDownloadsAllowed(max, channel.Settings.FileMaxDownloads)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
item = new Item(channelId, env.ItemId, principal.EndpointHash, env.ItemFlags, max, 0, 0, -1, null, 0,
|
||||
item = new Item(channelId, env.ItemId, principal.GrantKey, env.ItemFlags, max, 0, 0, -1, null, 0,
|
||||
now, env.IsControl ? long.MaxValue : now + ttlMs, null, principal.AccountName);
|
||||
tx.InsertItem(item);
|
||||
}
|
||||
@@ -417,7 +383,7 @@ public sealed partial class ChannelService(
|
||||
limit = Math.Clamp(limit, 1, 1000);
|
||||
var (pending, lastSeq) = store.Read(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (channel, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
|
||||
// 续传点早于已被清理的墓碑:客户端可能漏掉删除事件,必须从 0 全量同步
|
||||
@@ -463,7 +429,7 @@ public sealed partial class ChannelService(
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
@@ -498,7 +464,7 @@ public sealed partial class ChannelService(
|
||||
maxFrames = Math.Clamp(maxFrames, 1, 64);
|
||||
var (frames, next, complete) = store.Read(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
@@ -535,7 +501,7 @@ public sealed partial class ChannelService(
|
||||
|
||||
public ObjectEnvelope? ObjectSigningContext(Id16 id, Credentials creds, Id16 itemId) => store.Read(tx =>
|
||||
{
|
||||
_ = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
_ = Authenticate(tx, id, creds, Membership.Required);
|
||||
return tx.GetWorkspaceCrypto(id).Objects.Find(o => o.ItemId == itemId.ToBase64Url());
|
||||
});
|
||||
|
||||
@@ -543,7 +509,7 @@ public sealed partial class ChannelService(
|
||||
{
|
||||
var (seq, prefixes) = store.Write(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (channel, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (!ChannelPolicy.CanDeleteItem(channel, item, principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
|
||||
@@ -671,22 +637,18 @@ public sealed partial class ChannelService(
|
||||
|
||||
// ───────────────────────── 鉴权 ─────────────────────────
|
||||
|
||||
private (Channel Channel, Principal Principal) Authenticate(IResourceTx tx, Id16 id, Credentials creds, SeatRule seat)
|
||||
private (Channel Channel, Principal Principal) Authenticate(IResourceTx tx, Id16 id, Credentials creds, Membership seat)
|
||||
{
|
||||
// 频道强制登录:没有账号会话的请求一律拒绝(便捷分享不受影响)
|
||||
string account = creds.AccountName ?? throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var channel = tx.GetChannel(id);
|
||||
if (channel is null || channel.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.NotFound);
|
||||
|
||||
var role = VerifyRole(channel, creds);
|
||||
var member = creds.AccountName is null ? null : tx.GetChannelMember(id, creds.AccountName);
|
||||
var member = tx.GetChannelMember(id, account);
|
||||
if (role == Role.Owner && member?.Role != "owner") throw new WebDropException(ErrorCode.AccountRequired);
|
||||
if (seat == SeatRule.Required && member is null)
|
||||
{
|
||||
// 账号成员只凭账号会话鉴权,不再需要终端登记;未登录访客必须先有终端登记
|
||||
var endpoint = tx.GetSeat(id, creds.EndpointHash);
|
||||
if (endpoint is null) throw new WebDropException(ErrorCode.NotJoined);
|
||||
if (endpoint.AccountName is not null) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
}
|
||||
return (channel, new Principal(role, creds.EndpointHash, creds.AccountName));
|
||||
if (seat == Membership.Required && member is null) throw new WebDropException(ErrorCode.NotJoined);
|
||||
return (channel, new Principal(role, account));
|
||||
}
|
||||
|
||||
private static Role VerifyRole(Channel channel, Credentials creds)
|
||||
@@ -715,7 +677,7 @@ public sealed partial class ChannelService(
|
||||
|
||||
private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null) => new()
|
||||
{
|
||||
CanUpload = ChannelPolicy.CanWrite(channel, principal ?? new Principal(Role.Owner, [], channel.CreatorAccount)),
|
||||
CanUpload = ChannelPolicy.CanWrite(channel, principal ?? new Principal(Role.Owner, channel.CreatorAccount)),
|
||||
CanManageStructure = principal is null || principal.Role == Role.Owner,
|
||||
Id = channel.Id.ToBase64Url(),
|
||||
CreatorAccount = channel.CreatorAccount,
|
||||
|
||||
@@ -10,7 +10,7 @@ public sealed partial class ChannelService
|
||||
{
|
||||
var channel = tx.GetChannel(id)!;
|
||||
var role = tx.GetChannelMember(id, username)?.Role == "owner" ? Role.Owner : channel.WriteHash is not null ? Role.Write : Role.Read;
|
||||
return ToInfo(channel, tx.CountMembers(id), new Principal(role, [], username));
|
||||
return ToInfo(channel, tx.CountMembers(id), new Principal(role, username));
|
||||
}).ToList());
|
||||
private static WorkspaceState NewWorkspace(string username, long now)
|
||||
{
|
||||
@@ -19,7 +19,7 @@ public sealed partial class ChannelService
|
||||
}
|
||||
public WorkspaceState GetWorkspace(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
var state = tx.GetWorkspace(id) ?? new WorkspaceState();
|
||||
if (principal.AccountName != space.CreatorAccount)
|
||||
{
|
||||
@@ -42,8 +42,7 @@ public sealed partial class ChannelService
|
||||
{
|
||||
var state = store.Write(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
if (requireAccount && principal.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
var (channel, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
if (requireAccount && principal.Role != Role.Owner) throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (!ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
var current = tx.GetWorkspace(id) ?? new WorkspaceState();
|
||||
@@ -97,7 +96,7 @@ public sealed partial class ChannelService
|
||||
if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var item = tx.GetItem(id, parsed) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if ((item.Flags & (FrameFlags.Control | FrameFlags.Snapshot)) != 0) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (principal.Role != Role.Owner && (item.AuthorAccount is not null ? item.AuthorAccount != principal.AccountName : !item.AuthorHash.AsSpan().SequenceEqual(principal.EndpointHash))) throw new WebDropException(ErrorCode.Forbidden);
|
||||
if (principal.Role != Role.Owner && item.AuthorAccount != principal.AccountName) throw new WebDropException(ErrorCode.Forbidden);
|
||||
state.Locations.RemoveAll(l => l.ItemId == itemId);
|
||||
state.Locations.Add(new WorkspaceLocation { ItemId = itemId, ThreadId = threadId, FolderId = folderId, AuthorAccount = item.AuthorAccount });
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ public sealed class ChannelInfo
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
|
||||
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
|
||||
/// <summary>v5: 账号成员数加匿名访客终端数,不统计账号设备数。</summary>
|
||||
/// <summary>频道成员数(账号),不统计账号设备数。</summary>
|
||||
[JsonPropertyName("members_used")] public required int MembersUsed { get; init; }
|
||||
|
||||
/// <summary>从该 seq 起的续传有效;0 < from < oldest_seq 的续传会得到 410,需从 0 全量同步。</summary>
|
||||
@@ -78,13 +78,6 @@ public sealed class JoinResult
|
||||
[JsonPropertyName("members_max")] public required int MembersMax { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>未登录访客的一次加入(终端)。账号成员按用户管理,不在这里出现。</summary>
|
||||
public sealed class GuestInfo
|
||||
{
|
||||
[JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; }
|
||||
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AppendResult
|
||||
{
|
||||
[JsonPropertyName("seq")] public required long Seq { get; init; }
|
||||
|
||||
@@ -82,8 +82,6 @@ public sealed record Item(
|
||||
public bool IsComplete => FinalIndex is { } f && FrameCount == f + 1;
|
||||
}
|
||||
|
||||
/// <summary>频道内的终端登记(账号设备或访客终端)。设备只是账号的属性,不是成员;不再有单独的设备名。</summary>
|
||||
public sealed record Seat(byte[] EndpointHash, long JoinedAt, 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);
|
||||
@@ -101,13 +99,11 @@ 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, string? AccountName = null)
|
||||
/// <summary>一次请求验证通过后的身份。频道只接受已登录账号。</summary>
|
||||
public sealed record Principal(Role Role, string AccountName)
|
||||
{
|
||||
/// <summary>受限下载的计数单位:账号按用户计(设备只是账号的属性),访客按终端计。</summary>
|
||||
public byte[] GrantKey => AccountName is null
|
||||
? EndpointHash
|
||||
: System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes("webdrop/grant/v1/" + AccountName));
|
||||
/// <summary>受限下载的计数单位:按用户计(设备只是账号的属性)。</summary>
|
||||
public byte[] GrantKey => System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes("webdrop/grant/v1/" + AccountName));
|
||||
}
|
||||
|
||||
/// <summary>请求携带的凭据,尚未验证。</summary>
|
||||
|
||||
@@ -31,7 +31,6 @@ public static class ServerSettingChoices
|
||||
public sealed class ChannelSettings
|
||||
{
|
||||
[JsonPropertyName("join_policy")] public JoinPolicy JoinPolicy { get; set; } = JoinPolicy.Open;
|
||||
[JsonPropertyName("allow_guests")] public bool AllowGuests { get; set; } = true;
|
||||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||
/// <summary>成员人数上限;线字段名保留为 seats 以兼容已有频道设置。</summary>
|
||||
[JsonPropertyName("max_members")] public int MaxMembers { get; set; } = 2;
|
||||
@@ -56,7 +55,6 @@ public sealed class ChannelSettings
|
||||
public sealed class ChannelSettingsPatch
|
||||
{
|
||||
[JsonPropertyName("join_policy")] public JoinPolicy? 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("max_members")] public int? MaxMembers { get; init; }
|
||||
@@ -70,7 +68,6 @@ 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(),
|
||||
MaxMembers = MaxMembers ?? s.MaxMembers,
|
||||
MembersCanUpload = MembersCanUpload ?? s.MembersCanUpload,
|
||||
@@ -118,7 +115,7 @@ public static class CreationDefaults
|
||||
|
||||
public static ChannelSettings Channel(ServerLimits limits) => new()
|
||||
{
|
||||
JoinPolicy = JoinPolicy.Invite, AllowGuests = false,
|
||||
JoinPolicy = JoinPolicy.Invite,
|
||||
MaxMembers = Math.Min(10, limits.MaxMembers),
|
||||
TtlSeconds = Math.Clamp(86_400, limits.MinTtlSeconds, limits.MaxTtlSeconds),
|
||||
MaxItems = Math.Min(1000, limits.MaxItemsLimit),
|
||||
|
||||
@@ -32,11 +32,9 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
_writeConn = Open();
|
||||
Exec(_writeConn, "PRAGMA journal_mode=WAL;");
|
||||
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;");
|
||||
}
|
||||
// v8: 频道只接受登录账号,不再有访客席位;丢弃旧的席位表和针对终端的拒绝记录(幂等)
|
||||
Exec(_writeConn, "DROP TABLE IF EXISTS seats;");
|
||||
Exec(_writeConn, "DELETE FROM channel_join_denials WHERE target LIKE 'endpoint:%';");
|
||||
// v7: 设置里的成员人数上限由 seats 改名为 max_members,迁移已有频道的设置 JSON(幂等)
|
||||
Exec(_writeConn, "UPDATE channels SET settings = json_remove(json_set(settings, '$.max_members', json_extract(settings, '$.seats')), '$.seats') WHERE json_extract(settings, '$.seats') IS NOT NULL AND json_extract(settings, '$.max_members') IS NULL;");
|
||||
// Additive table: retain the running v2 database and its existing channel data.
|
||||
@@ -131,15 +129,6 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
);
|
||||
CREATE INDEX ix_channels_expires ON channels(expires_at);
|
||||
|
||||
CREATE TABLE seats (
|
||||
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;
|
||||
|
||||
CREATE TABLE items (
|
||||
channel_id BLOB NOT NULL,
|
||||
item_id BLOB NOT NULL,
|
||||
@@ -473,16 +462,14 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
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 bool IsJoinDenied(Id16 id, string username) => Scalar("SELECT 1 FROM channel_join_denials WHERE channel_id=@id AND target=@a LIMIT 1", ("@id", id.ToArray()), ("@a", "account:" + username)) 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()));
|
||||
@@ -500,7 +487,6 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
Run("DELETE FROM frames WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM log WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM items WHERE channel_id=@c", ("@c", key));
|
||||
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));
|
||||
@@ -513,41 +499,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
public IReadOnlyList<Id16> ListExpiredChannels(long now, int limit) =>
|
||||
Many("SELECT id FROM channels WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit));
|
||||
|
||||
// ───────── 席位 ─────────
|
||||
|
||||
private static Seat MapSeat(SqliteDataReader r) => new(Blob(r, 0), r.GetInt64(1), 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, 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 int CountMembers(Id16 channelId) =>
|
||||
(int)(long)Scalar("SELECT (SELECT COUNT(*) FROM channel_members WHERE channel_id=@c) + (SELECT COUNT(*) FROM seats WHERE channel_id=@c AND account_name IS NULL)", ("@c", channelId.ToArray()))!;
|
||||
|
||||
public void InsertSeat(Id16 channelId, Seat seat) => Run(
|
||||
"INSERT INTO seats (channel_id, endpoint_hash, joined_at,account_name,account_device_id) VALUES (@c,@e,@j,@a,@d)",
|
||||
("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@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",
|
||||
("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())) > 0;
|
||||
|
||||
public IReadOnlyList<Seat> ListSeats(Id16 channelId) => Many(
|
||||
"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));
|
||||
(int)(long)Scalar("SELECT COUNT(*) FROM channel_members WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
|
||||
|
||||
// ───────── 条目 ─────────
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
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 { name = "Device admission space", max_members = 2, join_policy = "open", allow_guests = true });
|
||||
var ch = await server.CreateAsync(settings: new { name = "Device admission space", max_members = 2, join_policy = "open" });
|
||||
using (var invite = await InviteAsync(ch, username, "opaque-key-package")) Assert.Equal(HttpStatusCode.NoContent, 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) })
|
||||
@@ -198,7 +198,6 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
{
|
||||
var request = JsonSerializer.Deserialize("{\"settings\":{\"max_members\":8}}", ApiJson.Default.CreateChannelRequest)!;
|
||||
Assert.Equal(JoinPolicy.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());
|
||||
@@ -214,9 +213,9 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
}
|
||||
}
|
||||
[Fact]
|
||||
public async Task AccountMembersNeedNoTerminalKeyAndDoNotOccupyGuestRegistrations()
|
||||
public async Task AccountMembersNeedNoTerminalKey()
|
||||
{
|
||||
var ch = await server.CreateAsync(settings: new { name = "Account only", max_members = 3, join_policy = "invite", allow_guests = false });
|
||||
var ch = await server.CreateAsync(settings: new { name = "Account only", max_members = 3, join_policy = "invite" });
|
||||
var username = "member_" + Guid.NewGuid().ToString("N")[..8];
|
||||
var account = await Register(username);
|
||||
using (var invite = await InviteAsync(ch, username, "opaque-key-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
|
||||
@@ -230,9 +229,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
log.Headers.Remove("X-WD-Endpoint");
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(log)).StatusCode);
|
||||
}
|
||||
// 账号成员不在访客清单里;成员清单里有它,设备数不小于在线设备数
|
||||
using (var guests = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests", ch.Owner))
|
||||
Assert.Equal(0, (await ReadJsonAsync(await server.Http.SendAsync(guests))).GetArrayLength());
|
||||
// 成员清单里有它,设备数不小于在线设备数
|
||||
using var members = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/members", ch.Owner);
|
||||
var list = await ReadJsonAsync(await server.Http.SendAsync(members));
|
||||
var mine = list.EnumerateArray().Single(m => m.GetProperty("username").GetString() == username);
|
||||
@@ -321,17 +318,17 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
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}/join", guest))
|
||||
var joiner = await Register("joiner_" + Guid.NewGuid().ToString("N")[..8]);
|
||||
using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, joiner))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(missing), 403, "channel_password_required");
|
||||
using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, body: new { password = "wrong" }))
|
||||
using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, joiner, 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}/join", guest, body: new { password = "channel-secret" }))
|
||||
using (var correct = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, joiner, 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}/join", guest with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }, body: new { password = "channel-secret" }))
|
||||
// 没有账号会话的请求一律被拒绝,哪怕链接令牌和密码都对
|
||||
using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, 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 })))
|
||||
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_policy = "invite" })))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
|
||||
var username = "invited_" + Guid.NewGuid().ToString("N")[..8];
|
||||
var account = await Register(username);
|
||||
@@ -341,9 +338,9 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
using (var invite = await InviteAsync(ch, username, "client-encrypted-payload")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode);
|
||||
using (var accepted = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, account))
|
||||
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accepted)).StatusCode);
|
||||
// 只有链接令牌与终端密钥、没有账号会话的请求没有成员身份,也没有访客登记
|
||||
// 只有链接令牌与终端密钥、没有账号会话的请求没有任何身份
|
||||
using (var withoutAccount = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(withoutAccount), 403, "not_joined");
|
||||
await AssertErrorAsync(await server.Http.SendAsync(withoutAccount), 401, "account_required");
|
||||
var writer = member with { Role = "write", Token = ch.WriteToken };
|
||||
string channelId;
|
||||
using (var structureRequest = server.Req(HttpMethod.Get, $"/api/v1/spaces/{ch.Id}/structure", ch.Owner))
|
||||
|
||||
@@ -126,11 +126,6 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
|
||||
using (var patch = await OwnerRequest(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", JsonBody(new { max_members = 3 })))
|
||||
Assert.Equal(HttpStatusCode.OK, patch.StatusCode);
|
||||
using (var guests = await OwnerRequest(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests"))
|
||||
Assert.Equal(HttpStatusCode.OK, guests.StatusCode);
|
||||
using (var absentGuest = await OwnerRequest(HttpMethod.Delete,
|
||||
$"/api/v1/channels/{ch.Id}/guests/{B64(RandomNumberGenerator.GetBytes(32))}"))
|
||||
await AssertErrorAsync(absentGuest, 404, "not_found");
|
||||
using (var deleted = await OwnerRequest(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}"))
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner)), 404, "not_found");
|
||||
@@ -232,7 +227,7 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JoinListGuestsAndRelease()
|
||||
public async Task JoinMembersAndRevoke()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
|
||||
@@ -241,26 +236,24 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
Assert.Equal(2, j.GetProperty("members_used").GetInt32());
|
||||
Assert.Equal(2, j.GetProperty("members_max").GetInt32());
|
||||
Assert.False(j.TryGetProperty("endpoint_hash", out _)); // 终端不再对外暴露
|
||||
string memberHash = B64(System.Security.Cryptography.SHA256.HashData(ch.Reader.EndpointSecret));
|
||||
|
||||
// 第三个终端被拒绝
|
||||
var third = new Who("read", ch.ReadToken, System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
|
||||
// 第三个账号被拒绝:成员已满
|
||||
var thirdAccount = s.NewAccountToken("third_" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var third = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32), thirdAccount);
|
||||
await AssertErrorAsync(await s.JoinAsync(ch, third), 403, "members_full");
|
||||
|
||||
// 访客清单只有所有者能看;账号成员不在其中
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests", ch.Reader)), 403, "forbidden");
|
||||
using var list = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests", ch.Owner));
|
||||
var guests = await ReadJsonAsync(list);
|
||||
Assert.Equal(1, guests.GetArrayLength());
|
||||
Assert.Equal(memberHash, guests[0].GetProperty("endpoint_hash").GetString());
|
||||
// 访客接口已移除
|
||||
using var guests = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/guests", ch.Owner));
|
||||
Assert.Equal(HttpStatusCode.NotFound, guests.StatusCode);
|
||||
|
||||
// 设备名接口已移除
|
||||
using var label = await s.Http.SendAsync(s.Req(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/seats/me", ch.Reader, Bytes([9, 8, 7])));
|
||||
Assert.Equal(HttpStatusCode.NotFound, label.StatusCode);
|
||||
// 没有账号会话的请求被拒绝
|
||||
using var anonymous = s.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", ch.Reader);
|
||||
anonymous.Headers.Remove("X-WD-Account");
|
||||
await AssertErrorAsync(await s.Http.SendAsync(anonymous), 401, "account_required");
|
||||
|
||||
// 所有者移除访客后,成员失去访问
|
||||
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/guests/{memberHash}", ch.Owner));
|
||||
Assert.Equal(HttpStatusCode.NoContent, release.StatusCode);
|
||||
// 所有者撤销成员后,成员失去访问
|
||||
using var revoke = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/fixture_member", ch.Owner));
|
||||
Assert.Equal(HttpStatusCode.NoContent, revoke.StatusCode);
|
||||
await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "not_joined");
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
|
||||
public HttpClient Http { get; private set; } = null!;
|
||||
public Uri BaseUri { get; private set; } = null!;
|
||||
public string OwnerAccountToken { get; private set; } = null!;
|
||||
/// <summary>非所有者的默认账号。频道只接受登录账号,所以测试里的「成员」「读者」都是这个账号。</summary>
|
||||
public string MemberAccountToken { get; private set; } = null!;
|
||||
|
||||
public ServerFixture() : this([]) { }
|
||||
|
||||
@@ -47,12 +49,20 @@ 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>();
|
||||
OwnerAccountToken = NewAccountToken("fixture_owner");
|
||||
MemberAccountToken = NewAccountToken("fixture_member");
|
||||
}
|
||||
|
||||
/// <summary>注册一个账号并返回它的会话令牌。</summary>
|
||||
public string NewAccountToken(string username)
|
||||
{
|
||||
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)!;
|
||||
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)!;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
@@ -82,7 +92,8 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
|
||||
public Who Reader => new("read", ReadToken, MemberSecret);
|
||||
}
|
||||
|
||||
public sealed record Who(string Role, byte[]? Token, byte[] EndpointSecret);
|
||||
/// <summary>AccountToken 为空时:所有者用 OwnerAccountToken,其余用 MemberAccountToken。</summary>
|
||||
public sealed record Who(string Role, byte[]? Token, byte[] EndpointSecret, string? AccountToken = null);
|
||||
|
||||
public static string B64(byte[] bytes) => Codec.Encode(bytes);
|
||||
public static string NewId() => B64(RandomNumberGenerator.GetBytes(16));
|
||||
@@ -93,7 +104,10 @@ 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);
|
||||
// 频道和空间只接受登录账号;便捷分享(/shares)仍由未登录终端使用
|
||||
if (path.StartsWith("/api/v1/channels", StringComparison.Ordinal) || path.StartsWith("/api/v1/spaces", StringComparison.Ordinal))
|
||||
req.Headers.Add("X-WD-Account", who.AccountToken ?? (who.Role == "owner" ? OwnerAccountToken : MemberAccountToken));
|
||||
else if (who.Role == "owner") req.Headers.Add("X-WD-Account", OwnerAccountToken);
|
||||
return req;
|
||||
}
|
||||
|
||||
@@ -118,7 +132,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
|
||||
};
|
||||
if (mode == ChannelMode.Enc) body["read_hash"] = B64(TokenHash.Compute(ch.ReadToken));
|
||||
// General transport tests explicitly use open spaces; creation defaults have separate coverage.
|
||||
body["settings"] = settings ?? new { name = "Test space", max_members = 2, join_policy = "open", allow_guests = true };
|
||||
body["settings"] = settings ?? new { name = "Test space", max_members = 2, join_policy = "open" };
|
||||
|
||||
using var req = Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
|
||||
if (capToken is not null) req.Headers.Add("X-WD-Cap", capToken);
|
||||
|
||||
@@ -20,7 +20,8 @@ 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))}");
|
||||
string account = who.AccountToken ?? (who.Role == "owner" ? s.OwnerAccountToken : s.MemberAccountToken);
|
||||
ws.Options.AddSubProtocol($"wd.account.{B64(Convert.FromBase64String(account))}");
|
||||
|
||||
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);
|
||||
@@ -106,23 +107,22 @@ public class StreamingTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
var ex = await Assert.ThrowsAsync<WebSocketException>(() => ConnectWs(ch, bad));
|
||||
Assert.Contains("401", ex.Message);
|
||||
|
||||
// 未入席
|
||||
// 未入席(已登录,但还不是成员)
|
||||
var stranger = new Who("write", ch.WriteToken, new byte[32]);
|
||||
var ex2 = await Assert.ThrowsAsync<WebSocketException>(() => ConnectWs(ch, stranger));
|
||||
Assert.Contains("403", ex2.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocketNotifiesSeatChangesAndClosesWhenRevoked()
|
||||
public async Task WebSocketNotifiesMemberChangesAndClosesWhenRevoked()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
await s.JoinAsync(ch, ch.Member);
|
||||
string memberHash = B64(System.Security.Cryptography.SHA256.HashData(ch.Member.EndpointSecret));
|
||||
|
||||
using var member = await ConnectWs(ch, ch.Member);
|
||||
|
||||
// 所有者释放席位:订阅者先收到 seats 控制消息,随后被以 not_joined 关闭
|
||||
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/guests/{memberHash}", ch.Owner));
|
||||
// 所有者撤销成员:订阅者先收到 members 控制消息,随后被以 not_joined 关闭
|
||||
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/fixture_member", ch.Owner));
|
||||
Assert.Equal(HttpStatusCode.NoContent, release.StatusCode);
|
||||
|
||||
bool closed = false;
|
||||
|
||||
@@ -45,7 +45,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, "owner"));
|
||||
AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, "owner"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -56,9 +56,9 @@ public class ChannelServiceTests : IDisposable
|
||||
var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, Settings = new ChannelSettings { Name = "Enc space" }, OwnerHash = h() };
|
||||
var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, Settings = new ChannelSettings { Name = "No owner space" } };
|
||||
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp, "owner"));
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp, "owner"));
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, _ownerEp, "owner"));
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, "owner"));
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, "owner"));
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, "owner"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -78,10 +78,10 @@ public class ChannelServiceTests : IDisposable
|
||||
public void WrongTokenIsUnauthorized()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, TestEnv.Random(32), _ownerEp)));
|
||||
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Read, null, _ownerEp)));
|
||||
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, TestEnv.Random(32), _ownerEp, "owner")));
|
||||
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Read, null, _ownerEp, "owner")));
|
||||
// 用读令牌冒充所有者
|
||||
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, ch.ReadToken, _ownerEp)));
|
||||
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, ch.ReadToken, _ownerEp, "owner")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -119,13 +119,10 @@ public class ChannelServiceTests : IDisposable
|
||||
Assert.Equal(2, Svc.Join(ch.Id, alice(memberOne, "alice-1")).MembersUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, alice(memberTwo, "alice-2")).MembersUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, ch.Owner(ownerTwo)).MembersUsed);
|
||||
Assert.Empty(Svc.ListGuests(ch.Id, ch.Owner(_ownerEp))); // 账号的设备不对外列出,也不计入成员人数
|
||||
|
||||
var bob = new Credentials(Role.Read, ch.ReadToken, TestEnv.Endpoint(), "bob", "bob-1");
|
||||
AssertCode(ErrorCode.MembersFull, () => Svc.Join(ch.Id, bob));
|
||||
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.ReleaseGuest(ch.Id, ch.Owner(_ownerEp), memberOne)); // 账号设备不能按设备移除,只能撤销用户
|
||||
|
||||
Svc.RevokeInvitation(ch.Id, ch.Owner(_ownerEp), "alice");
|
||||
Assert.Equal(1, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).MembersUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }).MembersUsed);
|
||||
@@ -151,7 +148,7 @@ public class ChannelServiceTests : IDisposable
|
||||
Settings = new ChannelSettings { Name = name },
|
||||
OwnerHash = Codec.Encode(TokenHash.Compute(TestEnv.Random(32))),
|
||||
};
|
||||
AssertCode(ErrorCode.SpaceNameRequired, () => Svc.CreateChannel(request, _ownerEp, "owner"));
|
||||
AssertCode(ErrorCode.SpaceNameRequired, () => Svc.CreateChannel(request, "owner"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -204,28 +201,36 @@ public class ChannelServiceTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReleasedSeatLosesAccess()
|
||||
public async Task RevokedMemberLosesAccess()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
Svc.Join(ch.Id, ch.Member(_memberEp));
|
||||
await AppendSmall(ch, ch.Member(_memberEp));
|
||||
|
||||
Svc.ReleaseGuest(ch.Id, ch.Owner(_ownerEp), _memberEp);
|
||||
Svc.RevokeInvitation(ch.Id, ch.Owner(_ownerEp), ch.AccountOf(_memberEp));
|
||||
await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp)));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.Join(ch.Id, ch.Member(_memberEp))); // 被移除的账号不能凭链接再进来
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyTheOwnerManagesGuestsAndSettings()
|
||||
public async Task ChannelsRequireALoggedInAccount()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var anonymous = new Credentials(Role.Read, ch.ReadToken, _memberEp);
|
||||
AssertCode(ErrorCode.AccountRequired, () => Svc.GetChannel(ch.Id, anonymous));
|
||||
AssertCode(ErrorCode.AccountRequired, () => Svc.Join(ch.Id, anonymous));
|
||||
await AssertCode(ErrorCode.AccountRequired, () => LogOf(ch, anonymous));
|
||||
await AssertCode(ErrorCode.AccountRequired, () => AppendSmall(ch, anonymous with { ClaimedRole = Role.Write, Token = ch.WriteToken }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyTheOwnerManagesSettings()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
Svc.Join(ch.Id, ch.Member(_memberEp));
|
||||
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.ListGuests(ch.Id, ch.Member(_memberEp)));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.UpdateSettings(ch.Id, ch.Member(_memberEp), new ChannelSettingsPatch { MaxMembers = 5 }));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.DeleteChannel(ch.Id, ch.Member(_memberEp)));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.ReleaseGuest(ch.Id, ch.Member(_memberEp), _ownerEp));
|
||||
|
||||
Assert.Single(Svc.ListGuests(ch.Id, ch.Owner(_ownerEp))); // 访客只有成员那一台
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -238,8 +243,6 @@ public class ChannelServiceTests : IDisposable
|
||||
// Owner management uses A; an owner on a new device can manage a full channel without first taking a seat.
|
||||
Assert.Equal(ch.Id.ToBase64Url(), Svc.GetChannel(ch.Id, unseatedOwner).Id);
|
||||
Assert.Equal(3, Svc.UpdateSettings(ch.Id, unseatedOwner, new ChannelSettingsPatch { MaxMembers = 3 }).Settings.MaxMembers);
|
||||
Assert.Single(Svc.ListGuests(ch.Id, unseatedOwner));
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.ReleaseGuest(ch.Id, unseatedOwner, TestEnv.Endpoint()));
|
||||
Svc.DeleteChannel(ch.Id, unseatedOwner);
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
|
||||
}
|
||||
@@ -346,7 +349,7 @@ public class ChannelServiceTests : IDisposable
|
||||
var enc = _env.Create(_ownerEp, ChannelMode.Enc);
|
||||
await Append(raw, raw.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 10));
|
||||
|
||||
var anonymous = new Credentials(Role.Read, null, _memberEp);
|
||||
var anonymous = new Credentials(Role.Read, null, _memberEp, "reader"); // raw 频道知道 id 即可读,但仍要登录
|
||||
Svc.Join(raw.Id, anonymous);
|
||||
Assert.Single((await Svc.ReadLogAsync(raw.Id, anonymous, 0, 10)).Records);
|
||||
|
||||
|
||||
@@ -116,8 +116,8 @@ public class PolicyTests
|
||||
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")));
|
||||
Assert.True(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, "member")));
|
||||
Assert.False(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, "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, "owner");
|
||||
@@ -129,12 +129,12 @@ public class PolicyTests
|
||||
public void WritePermission()
|
||||
{
|
||||
var ep = TestEnv.Endpoint();
|
||||
Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Owner, ep)));
|
||||
Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Write, ep)));
|
||||
Assert.False(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Read, ep)));
|
||||
Assert.False(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Write, ep)));
|
||||
Assert.False(ChannelPolicy.CanWrite(Chan(new(), writeHash: false), new Principal(Role.Write, ep)));
|
||||
Assert.True(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Owner, ep)));
|
||||
Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Owner, "someone")));
|
||||
Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Write, "someone")));
|
||||
Assert.False(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Read, "someone")));
|
||||
Assert.False(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Write, "someone")));
|
||||
Assert.False(ChannelPolicy.CanWrite(Chan(new(), writeHash: false), new Principal(Role.Write, "someone")));
|
||||
Assert.True(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Owner, "someone")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -146,10 +146,9 @@ public class PolicyTests
|
||||
[InlineData(DeletePolicy.Anyone, Role.Read, false, true)]
|
||||
public void DeletePermission(DeletePolicy policy, Role role, bool isAuthor, bool expected)
|
||||
{
|
||||
var me = TestEnv.Endpoint();
|
||||
var item = ItemBy(isAuthor ? me : TestEnv.Endpoint());
|
||||
var item = ItemBy(TestEnv.Endpoint()) with { AuthorAccount = isAuthor ? "me" : "someone-else" };
|
||||
var channel = Chan(new() { DeletePolicy = policy });
|
||||
Assert.Equal(expected, ChannelPolicy.CanDeleteItem(channel, item, new Principal(role, me)));
|
||||
Assert.Equal(expected, ChannelPolicy.CanDeleteItem(channel, item, new Principal(role, "me")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -30,9 +30,11 @@ public sealed class TestChannel(Id16 id, ChannelMode mode, byte[] read, byte[] w
|
||||
public byte[] WriteToken { get; } = write;
|
||||
public byte[] OwnerToken { get; } = owner;
|
||||
|
||||
/// <summary>测试里用 endpoint 当作「哪一个人」:同一个 endpoint 对应同一个账号。</summary>
|
||||
public string AccountOf(byte[] endpoint) => endpoint.AsSpan().SequenceEqual(ownerEndpoint) ? "owner" : "u" + Convert.ToHexString(endpoint)[..8].ToLowerInvariant();
|
||||
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 Credentials Member(byte[] endpoint) => new(Role.Write, WriteToken, endpoint, AccountOf(endpoint));
|
||||
public Credentials Reader(byte[] endpoint) => new(Role.Read, ReadToken, endpoint, AccountOf(endpoint));
|
||||
}
|
||||
|
||||
public sealed class TestEnv : IDisposable
|
||||
@@ -78,7 +80,7 @@ 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, "owner");
|
||||
}, "owner");
|
||||
|
||||
return new TestChannel(id, mode, read, write, owner, ownerEndpoint);
|
||||
}
|
||||
|
||||
@@ -57,15 +57,15 @@ public sealed class WorkspaceStructureTests
|
||||
Assert.Equal("新频道", added.Channels.Single().Name); Assert.Equal(1, added.Revision);
|
||||
}
|
||||
[Fact]
|
||||
public async Task AllowedGuestsCanPlaceTheirUploadsButCannotCreateBusinessStructure()
|
||||
public async Task MembersCanPlaceTheirUploadsButCannotCreateBusinessStructure()
|
||||
{
|
||||
using var env = new TestEnv(); var ownerEp = TestEnv.Endpoint(); var guestEp = TestEnv.Endpoint(); var ch = env.Create(ownerEp);
|
||||
var guest = ch.Member(guestEp); env.Service.Join(ch.Id, guest);
|
||||
using var env = new TestEnv(); var ownerEp = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var ch = env.Create(ownerEp);
|
||||
var guest = ch.Member(memberEp); env.Service.Join(ch.Id, guest);
|
||||
var item = TestEnv.NewId();
|
||||
await env.Service.AppendFrameAsync(ch.Id, guest, TestEnv.Frame(item, 0, TestEnv.SmallEnc, TestEnv.EncPayload), null);
|
||||
var channelId = env.Service.GetWorkspace(ch.Id, ch.Owner(ownerEp)).Channels.Single().Id;
|
||||
Assert.Single(env.Service.MoveWorkspaceItems(ch.Id, guest, [item.ToBase64Url()], channelId, null).Locations);
|
||||
Assert.Equal(ErrorCode.AccountRequired, Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceGroup(ch.Id, guest, "访客分组")).Code);
|
||||
Assert.Equal(ErrorCode.Forbidden, Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceGroup(ch.Id, guest, "成员分组")).Code);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -68,6 +68,8 @@ export function App() {
|
||||
else if (route.name === 'share' && waitingOnSavedShare) screen = null;
|
||||
else if (route.name === 'share') screen = <ShareScreen id={route.id} />;
|
||||
else if (route.name === 'channel' && waitingOnSavedChannel) screen = null;
|
||||
// 空间只接受登录账号:未登录先登录,链接留在内存里,登录后继续加入
|
||||
else if (route.name === 'channel' && !username) screen = <AccountScreen />;
|
||||
else if (saved) screen = <ChannelScreen id={route.id} />;
|
||||
else if (pendingLink?.id === route.id) {
|
||||
screen = (
|
||||
|
||||
@@ -3,9 +3,9 @@ import { supportsApiContract } from './contract';
|
||||
|
||||
describe('API contract compatibility', () => {
|
||||
it('accepts compatible SemVer releases in the supported major', () => {
|
||||
expect(supportsApiContract('7.0.0')).toBe(true);
|
||||
expect(supportsApiContract('7.8.2')).toBe(true);
|
||||
expect(supportsApiContract('7.2.0-rc.1')).toBe(true);
|
||||
expect(supportsApiContract('8.0.0')).toBe(true);
|
||||
expect(supportsApiContract('8.8.2')).toBe(true);
|
||||
expect(supportsApiContract('8.2.0-rc.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a breaking major and malformed versions', () => {
|
||||
@@ -13,6 +13,7 @@ describe('API contract compatibility', () => {
|
||||
expect(supportsApiContract('4.0.0')).toBe(false);
|
||||
expect(supportsApiContract('5.0.0')).toBe(false);
|
||||
expect(supportsApiContract('6.1.0')).toBe(false);
|
||||
expect(supportsApiContract('7.0.0')).toBe(false);
|
||||
expect(supportsApiContract('01.0.0')).toBe(false);
|
||||
expect(supportsApiContract(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const SUPPORTED_API_CONTRACT_MAJOR = 7;
|
||||
export const SUPPORTED_API_CONTRACT_MAJOR = 8;
|
||||
|
||||
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-]+)*)?$/;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Generated from backend DTOs; do not edit.
|
||||
// backend-contract-sha256: 54ea3f00fa672e0241efbb9fc8e7ad9e8fc68415ec124e08f70657c2d57d9ff9
|
||||
// backend-contract-sha256: 655e918f3443073c270bdeb9ae4e69d79dacf83baa90dea1a0cdcfe71d2b8bd5
|
||||
|
||||
export interface AccountChannelBody {
|
||||
channel_id: string;
|
||||
@@ -88,7 +88,6 @@ export interface ChannelInfo {
|
||||
export type ChannelMode = 'enc' | 'raw';
|
||||
|
||||
export interface ChannelSettings {
|
||||
allow_guests: boolean;
|
||||
delete_policy: DeletePolicy;
|
||||
file_max_downloads: number;
|
||||
join_policy: JoinPolicy;
|
||||
@@ -100,7 +99,6 @@ export interface ChannelSettings {
|
||||
ui_restrict_copy_download: boolean;
|
||||
}
|
||||
export interface ChannelSettingsPatch {
|
||||
allow_guests?: boolean | null;
|
||||
delete_policy?: DeletePolicy | null;
|
||||
file_max_downloads?: number | null;
|
||||
join_password?: string | null;
|
||||
@@ -171,10 +169,6 @@ export interface GoneLogBody {
|
||||
error: string;
|
||||
oldest_seq: number;
|
||||
}
|
||||
export interface GuestInfo {
|
||||
endpoint_hash: string;
|
||||
joined_at: number;
|
||||
}
|
||||
export interface InvitationBody {
|
||||
channel_id: string;
|
||||
created_at: number;
|
||||
|
||||
@@ -4,5 +4,5 @@ import type { CreateOptions } from './types';
|
||||
export function creationOptions(defaults: DefaultsBody): CreateOptions {
|
||||
const settings = defaults.channel;
|
||||
return { mode: defaults.channel_mode, maxMembers: settings.max_members, ttlSeconds: settings.ttl_seconds, membersCanUpload: settings.members_can_upload,
|
||||
fileMaxDownloads: settings.file_max_downloads, joinPolicy: settings.join_policy, allowGuests: settings.allow_guests };
|
||||
fileMaxDownloads: settings.file_max_downloads, joinPolicy: settings.join_policy };
|
||||
}
|
||||
@@ -8,7 +8,6 @@ export interface ChannelInfo {
|
||||
dataSource?: 'server' | 'cache';
|
||||
creatorAccount?: string;
|
||||
joinPolicy?: 'open' | 'invite';
|
||||
allowGuests?: boolean;
|
||||
passwordRequired?: boolean;
|
||||
id: string;
|
||||
/** 服务端 channel settings 中的共享名称,加入者可见。 */
|
||||
@@ -33,7 +32,6 @@ export interface ChannelInfo {
|
||||
/** 新建空间的选项(protocol §12 设置的子集) */
|
||||
export interface CreateOptions {
|
||||
joinPolicy?: 'open' | 'invite';
|
||||
allowGuests?: boolean;
|
||||
joinPassword?: string;
|
||||
name?: string;
|
||||
mode: ChannelMode;
|
||||
@@ -51,7 +49,6 @@ export interface FileUploadOptions {
|
||||
|
||||
export interface ChannelSettingsPatch {
|
||||
joinPolicy?: 'open' | 'invite';
|
||||
allowGuests?: boolean;
|
||||
joinPassword?: string;
|
||||
name?: string;
|
||||
maxMembers?: number;
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
|
||||
ttl_seconds: options.ttlSeconds,
|
||||
members_can_upload: options.membersCanUpload,
|
||||
file_max_downloads: options.fileMaxDownloads,
|
||||
join_policy: options.joinPolicy ?? defaults.join_policy, allow_guests: options.allowGuests ?? defaults.allow_guests,
|
||||
join_policy: options.joinPolicy ?? defaults.join_policy,
|
||||
},
|
||||
});
|
||||
try {
|
||||
@@ -157,7 +157,6 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
|
||||
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
|
||||
if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads;
|
||||
if (patch.joinPolicy !== undefined) body.join_policy = patch.joinPolicy;
|
||||
if (patch.allowGuests !== undefined) body.allow_guests = patch.allowGuests;
|
||||
if (patch.joinPassword !== undefined) body.join_password = patch.joinPassword;
|
||||
const info = await apiFetch(`/channels/${resourceId}/settings`, {
|
||||
method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -8,7 +8,7 @@ describe('API contract preflight', () => {
|
||||
});
|
||||
|
||||
it('blocks API requests when the server has a newer breaking major', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '8.0.0' }), { status: 200 }));
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '9.0.0' }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
setApiBase('http://api.test/api/v1');
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('API contract preflight', () => {
|
||||
|
||||
it('checks the contract once and then sends the API request', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '7.0.0' }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '8.0.0' }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
@@ -8,7 +8,7 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn
|
||||
return {
|
||||
id: raw.id,
|
||||
creatorAccount: raw.creator_account ?? undefined,
|
||||
joinPolicy, allowGuests: raw.settings.allow_guests, passwordRequired: raw.password_required,
|
||||
joinPolicy, passwordRequired: raw.password_required,
|
||||
name: raw.settings.name ?? undefined,
|
||||
mode: raw.mode,
|
||||
role: raw.can_manage_structure ? 'owner' : 'member',
|
||||
|
||||
@@ -16,7 +16,7 @@ interface Props {
|
||||
|
||||
export function ChannelSettingsSheet(props: Props) {
|
||||
const { info, error, load } = useServerSettings();
|
||||
if (!info || props.channel.ttlSeconds === undefined || props.channel.membersCanUpload === undefined || props.channel.maxDownloads === undefined || props.channel.joinPolicy === undefined || props.channel.allowGuests === undefined)
|
||||
if (!info || props.channel.ttlSeconds === undefined || props.channel.membersCanUpload === undefined || props.channel.maxDownloads === undefined || props.channel.joinPolicy === undefined)
|
||||
return <Sheet title="空间设置" onClose={props.onClose}><p role={error ? 'alert' : 'status'}>{error ?? '正在读取空间设置…'}</p>
|
||||
{error ? <Button onClick={() => { void load().catch(() => {}); }}>重试</Button> : null}</Sheet>;
|
||||
return <SettingsForm {...props} info={info} />;
|
||||
@@ -29,7 +29,6 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds!);
|
||||
const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads!);
|
||||
const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy!);
|
||||
const [allowGuests, setAllowGuests] = useState(channel.allowGuests!);
|
||||
const [joinPassword, setJoinPassword] = useState('');
|
||||
const [clearPassword, setClearPassword] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -42,7 +41,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
setSaving(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
|
||||
await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy,
|
||||
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
|
||||
onClose();
|
||||
} catch {
|
||||
@@ -87,10 +86,9 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
|
||||
<label className={styles.field}><span>加入方式</span>
|
||||
<select value={joinPolicy} disabled={!owner} onChange={(event) => setJoinPolicy(event.target.value as 'open' | 'invite')}>
|
||||
<option value="open">持链接加入</option><option value="invite">仅受邀账号加入</option>
|
||||
<option value="open">登录后持链接加入</option><option value="invite">仅受邀账号加入</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.check}><input type="checkbox" checked={allowGuests} disabled={!owner || joinPolicy === 'invite'} onChange={(event) => setAllowGuests(event.target.checked)} /><span>允许未登录的陌生人加入</span></label>
|
||||
<label className={styles.field}><span>{channel.passwordRequired ? '更换空间加入密码' : '空间加入密码(可选)'}</span>
|
||||
<input type="password" autoComplete="new-password" minLength={8} maxLength={256} value={joinPassword} disabled={!owner || clearPassword} placeholder="至少 8 个字符,留空不修改" onChange={(event) => setJoinPassword(event.target.value)} />
|
||||
<span className={styles.note}>空间主及受邀用户无需加入密码。修改规则不移除已加入成员。</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { ArchiveEntry, Item } from '@/core-bridge/types';
|
||||
import { GUEST_LABEL } from '@/lib/sender';
|
||||
import { UNKNOWN_SENDER_LABEL } from '@/lib/sender';
|
||||
import { useShareSession } from '@/store/shareSession';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface ItemSource {
|
||||
|
||||
export const channelSource: ItemSource = {
|
||||
useItem: (id) => useWorkspace((s) => s.byId[id]),
|
||||
fallbackSender: GUEST_LABEL,
|
||||
fallbackSender: UNKNOWN_SENDER_LABEL,
|
||||
getBlob: (id, versionId) => useWorkspace.getState().getBlob(id, versionId),
|
||||
listArchive: (id) => useWorkspace.getState().listArchive(id),
|
||||
actions: {
|
||||
|
||||
@@ -48,7 +48,7 @@ export function JoinPreview({ link, onJoin, onJoined, onBack, backLabel = '重
|
||||
{link.isOwner ? '所有者 · 含管理权限' : link.canWrite ? '成员 · 可上传' : '成员'}
|
||||
</dd>
|
||||
</dl>
|
||||
<p className={styles.dim}>{preview?.joinPolicy === 'invite' ? '这个空间仅允许受邀账号加入。' : preview?.allowGuests === false ? '这个空间需要登录账号后加入。' : '未登录用户将以陌生人设备身份加入。'}符合加入规则后可读取空间历史。</p>
|
||||
<p className={styles.dim}>{preview?.joinPolicy === 'invite' ? '这个空间仅允许受邀账号加入。' : '登录账号后即可加入。'}符合加入规则后可读取空间历史。</p>
|
||||
{preview?.passwordRequired ? <label>空间加入密码<input type="password" autoComplete="off" maxLength={256} value={password} onChange={(event) => setPassword(event.target.value)} /></label> : null}
|
||||
{phase === 'failed' ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
<div className={styles.row}>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { SenderInfo } from '@/core-bridge/types';
|
||||
|
||||
/** 没有账号归属的内容(未登录访客)的显示名;设备只是账号的属性,不作为发送者展示 */
|
||||
export const GUEST_LABEL = '访客';
|
||||
/** 找不到作者信息时的显示名(频道只有登录账号,正常不会用到);设备只是账号的属性,不作为发送者展示 */
|
||||
export const UNKNOWN_SENDER_LABEL = '未知成员';
|
||||
|
||||
export function senderLabel(source: { author?: SenderInfo } | undefined, fallback = GUEST_LABEL): string {
|
||||
export function senderLabel(source: { author?: SenderInfo } | undefined, fallback = UNKNOWN_SENDER_LABEL): string {
|
||||
return source?.author?.displayName || source?.author?.username || fallback;
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
// Restore the account scope before loading any account/guest resources.
|
||||
// Restore the account scope before loading any account resources.
|
||||
void useAccount.getState().refresh().then(() => Promise.all([
|
||||
useChannels.getState().hydrate(), useShares.getState().hydrate(),
|
||||
]));
|
||||
Reference in new issue
Block a user