diff --git a/contracts/api-contract.json b/contracts/api-contract.json index c06fc47..306a0a4 100644 --- a/contracts/api-contract.json +++ b/contracts/api-contract.json @@ -1,3 +1,3 @@ { - "version": "6.0.0" + "version": "7.0.0" } diff --git a/contracts/api.schemas.json b/contracts/api.schemas.json index 8ec56a0..57a0712 100644 --- a/contracts/api.schemas.json +++ b/contracts/api.schemas.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "x-backend-contract-sha256": "ba787115471d5d33fb0c33c95af014b5cd0d535455bd4bea037cfdcc4f048828", + "x-backend-contract-sha256": "54ea3f00fa672e0241efbb9fc8e7ad9e8fc68415ec124e08f70657c2d57d9ff9", "$defs": { "AccountChannelBody": { "type": "object", @@ -322,6 +322,9 @@ "last_seq": { "type": "integer" }, + "members_used": { + "type": "integer" + }, "mode": { "$ref": "#/$defs/ChannelMode" }, @@ -331,9 +334,6 @@ "password_required": { "type": "boolean" }, - "seats_used": { - "type": "integer" - }, "settings": { "$ref": "#/$defs/ChannelSettings" } @@ -345,10 +345,10 @@ "expires_at", "id", "last_seq", + "members_used", "mode", "oldest_seq", "password_required", - "seats_used", "settings" ] }, @@ -377,6 +377,9 @@ "max_items": { "type": "integer" }, + "max_members": { + "type": "integer" + }, "members_can_upload": { "type": "boolean" }, @@ -390,9 +393,6 @@ } ] }, - "seats": { - "type": "integer" - }, "ttl_seconds": { "type": "integer" }, @@ -406,8 +406,8 @@ "file_max_downloads", "join_policy", "max_items", + "max_members", "members_can_upload", - "seats", "ttl_seconds", "ui_restrict_copy_download" ] @@ -475,6 +475,16 @@ } ] }, + "max_members": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, "members_can_upload": { "anyOf": [ { @@ -495,16 +505,6 @@ } ] }, - "seats": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] - }, "ttl_seconds": { "anyOf": [ { @@ -841,6 +841,21 @@ "oldest_seq" ] }, + "GuestInfo": { + "type": "object", + "properties": { + "endpoint_hash": { + "type": "string" + }, + "joined_at": { + "type": "integer" + } + }, + "required": [ + "endpoint_hash", + "joined_at" + ] + }, "InvitationBody": { "type": "object", "properties": { @@ -963,6 +978,25 @@ }, "required": [] }, + "JoinResult": { + "type": "object", + "properties": { + "joined_at": { + "type": "integer" + }, + "members_max": { + "type": "integer" + }, + "members_used": { + "type": "integer" + } + }, + "required": [ + "joined_at", + "members_max", + "members_used" + ] + }, "KeyPackageRequest": { "type": "object", "properties": { @@ -977,9 +1011,18 @@ "MemberBody": { "type": "object", "properties": { + "devices": { + "type": "integer" + }, "joined_at": { "type": "integer" }, + "online": { + "type": "boolean" + }, + "online_devices": { + "type": "integer" + }, "role": { "type": "string" }, @@ -988,7 +1031,10 @@ } }, "required": [ + "devices", "joined_at", + "online", + "online_devices", "role", "username" ] @@ -1270,64 +1316,6 @@ "public_key" ] }, - "SeatInfo": { - "type": "object", - "properties": { - "account_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "endpoint_hash": { - "type": "string" - }, - "joined_at": { - "type": "integer" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "endpoint_hash", - "joined_at" - ] - }, - "SeatJoined": { - "type": "object", - "properties": { - "endpoint_hash": { - "type": "string" - }, - "joined_at": { - "type": "integer" - }, - "seats_max": { - "type": "integer" - }, - "seats_used": { - "type": "integer" - } - }, - "required": [ - "endpoint_hash", - "joined_at", - "seats_max", - "seats_used" - ] - }, "ServerInfoBody": { "type": "object", "properties": { @@ -1419,10 +1407,10 @@ "max_items": { "type": "integer" }, - "max_payload_bytes": { + "max_members": { "type": "integer" }, - "max_seats": { + "max_payload_bytes": { "type": "integer" }, "max_ttl_seconds": { @@ -1445,8 +1433,8 @@ "max_channel_bytes", "max_frames_per_item", "max_items", + "max_members", "max_payload_bytes", - "max_seats", "max_ttl_seconds", "max_visitors", "max_workspace_bytes", @@ -1463,13 +1451,13 @@ "type": "integer" } }, - "max_visitors": { + "max_members": { "type": "array", "items": { "type": "integer" } }, - "seats": { + "max_visitors": { "type": "array", "items": { "type": "integer" @@ -1484,8 +1472,8 @@ }, "required": [ "file_max_downloads", + "max_members", "max_visitors", - "seats", "ttl_seconds" ] }, diff --git a/docs/account-channel-architecture.md b/docs/account-channel-architecture.md index 7ea44b4..8f21768 100644 --- a/docs/account-channel-architecture.md +++ b/docs/account-channel-architecture.md @@ -24,7 +24,7 @@ Account 使用不可修改的 username 作为稳定身份,displayName/avatar 账号邀请私钥保存在密码加密的保险库中,多台设备登录同一账号后解锁同一账号私钥。空间创建时生成空间访问凭据,Owner 凭据与 Owner 账号密钥包由后端原子存储;它们不再加密频道内容。频道 chat/files 域密钥在浏览器生成,并通过 Owner 账号 ECDH 公钥封装;服务端保存密文,获授权成员的账号可领取并在本机解封。 -已加入成员的新设备只凭账号登录即可解封账号分发给该账号的数据域密钥,不需要邀请者在线,也不需要复制带内容密钥的链接。每个浏览器有独立设备登录凭据和独立空间终端 S;读取账号空间列表不占成员名额,实际打开空间才登记设备席位。同一账号在一个空间只占一个成员名额,设备数量不受成员上限影响。设备撤销会撤销其账号 sessions;账号空间成员身份及其人数名额保留,其他设备继续访问。 +已加入成员的新设备只凭账号登录即可解封账号分发给该账号的数据域密钥,不需要邀请者在线,也不需要复制带内容密钥的链接。每个浏览器有独立设备登录凭据(账号设备),成员请求凭账号 session 鉴权,不再有按空间的终端密钥或设备席位;读取账号空间列表不占成员名额。同一账号在一个空间只占一个成员名额,设备数量不受成员上限影响。设备撤销会撤销其账号 sessions;账号空间成员身份及其人数名额保留,其他设备继续访问。服务端按账号统计在线状态与设备数,成员栏只展示用户。 本机记住登录保存 session token、不可导出的保险库 CryptoKey 和加密保险库。勾选记住账号密码时,将填充资料用另一把不可导出的本机 AES-GCM 密钥加密保存;不保存 verifier。退出清除自动恢复 session,保留已选择保存的填充资料,明确清除时全部移除。登录态与本机资源数据库在同一启动屏障后恢复,避免先加载访客资源再切换账号。 diff --git a/docs/api.md b/docs/api.md index 1268f30..8877f1e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,9 +4,9 @@ 空间结构使用 `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` 为 `6.0.0`。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;公开链接仍允许访客按频道加入规则访问。 +URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `7.0.0`。v7 把「设备」收束为账号的属性:成员只按用户管理,`seats` 系列命名改为 `max_members` / `members_used` / `members_full`,去掉设备名与设备清单接口,访客改走 `/guests`,登录账号不再需要终端密钥。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;公开链接仍允许访客按频道加入规则访问。 -`GET /info` 的 `defaults` 包含 `channel`、`share` 创建设置;省略创建请求的 settings 时服务端使用相同默认值。`choices.seats` 与 `limits.max_seats` 表示成员人数范围,不代表设备数。频道设置 JSON 字段 `seats` 为保持已有频道配置可读而保留其名称;v5 起它限制的是成员数。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。 +`GET /info` 的 `defaults` 包含 `channel`、`share` 创建设置;省略创建请求的 settings 时服务端使用相同默认值。`choices.max_members` 与 `limits.max_members` 表示成员人数范围,不代表设备数。频道设置 JSON 字段为 `max_members`(旧存储里的 `seats` 在启动时迁移)。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。 基础路径 `/api/v1`。所有请求和响应的二进制数据格式见 [protocol.md](protocol.md)。前端托管在独立域名,服务端需要配置 CORS 白名单。 @@ -16,7 +16,7 @@ URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `6.0.0`。v6 引 |---|---| | `X-WD-Role` | `read` / `write` / `owner`,指明 `Authorization` 里的令牌属于哪种角色 | | `Authorization` | `Bearer ` | -| `X-WD-Endpoint` | `base64url(S)`,终端密钥,用于席位 | +| `X-WD-Endpoint` | `base64url(S)`,终端密钥。**只有未登录访客必填**;登录账号以 `X-WD-Account` 会话鉴权,可省略 | | `X-WD-Cap` | 创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11) | | `X-WD-Max-Downloads` | 第 0 帧上带,条目的下载上限,0 为不限 | | `X-WD-Account` | 账号 session token。与频道/分享的能力令牌分开;创建、管理和账号成员请求必填,访客省略 | @@ -30,10 +30,10 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w | 操作 | 需要 | |---|---| -| 读日志、拉取条目 | `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. 接口 @@ -66,7 +66,7 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w { "id": "", "mode": "enc", - "settings": { "name": "工作设备", "seats": 2, "members_can_upload": true, "delete_policy": "own", + "settings": { "name": "项目组", "max_members": 2, "members_can_upload": true, "delete_policy": "own", "ttl_seconds": 86400, "max_items": 1000, "file_max_downloads": 10 }, "read_hash": "", "write_hash": "", @@ -74,22 +74,22 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w } ``` -raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创建者的终端(`X-WD-Endpoint`)自动入席。返回 `201`,响应体与「读取频道信息」相同(`ChannelInfo`)。 +raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创建者自动成为频道的所有者成员。返回 `201`,响应体与「读取频道信息」相同(`ChannelInfo`)。 ### `GET /api/v1/channels/{id}` -返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、成员名额占用(`seats_used`)、`oldest_seq`、`last_seq`、时间。`seats_used` 统计唯一账号成员和匿名访客终端,不统计账号的设备数。**不占成员名额**,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。 +返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、成员名额占用(`members_used`)、`oldest_seq`、`last_seq`、时间。`members_used` 统计唯一账号成员和匿名访客终端,不统计账号的设备数。**不占成员名额**,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。 -### `POST /api/v1/channels/{id}/seats` -为一个终端加入频道。每个设备终端各自建立席位以便认证、重命名和撤销,但席位不再受人数上限约束。账号第一次加入时占一个成员名额;同一账号的更多设备不增加名额。同一设备重复调用幂等。成员名额已满时,新的账号成员或访客终端返回 `403 seats_full`。匿名访客无法跨设备识别,每个访客终端各计一个成员名额。读取频道信息和 owner 管理接口不占成员名额;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 `403 not_joined`。 +### `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}`,不再返回终端标识。 -可发送 JSON `{password}`。默认 `join_policy=open,allow_guests=true`,允许持链接的陌生人设备加入。`allow_guests=false` 要求账号登录;`join_policy=invite` 要求该账号已受邀。频道主、已加入的账号成员和受邀账号无需再次输入频道密码。密码由服务端保存加盐 PBKDF2 哈希,从不通过频道信息返回。成员账号和设备终端身份独立:同一账号可拥有不限数量的设备席位,但只占一个成员名额;账号成员的读写请求必须出示对应账号 session 和该设备 S。 +可发送 JSON `{password}`。默认 `join_policy=open,allow_guests=true`,允许持链接的陌生人设备加入。`allow_guests=false` 要求账号登录;`join_policy=invite` 要求该账号已受邀。频道主、已加入的账号成员和受邀账号无需再次输入频道密码。密码由服务端保存加盐 PBKDF2 哈希,从不通过频道信息返回。设备是账号的属性:同一账号可在任意多台设备登录,只占一个成员名额;账号成员的读写请求出示有效账号 session 即可,不再绑定到某个终端。 -`GET /channels/{id}/members` 返回频道内账号成员及 `owner|member` 角色。邀请前,频道主调用 `GET /channels/{id}/invitations/{username}/crypto-plan` 获取服务端根据成员权限和历史设置计算的域/epoch/收件人计划。浏览器只为计划中的收件人生成并封装域密钥;随后 `POST /channels/{id}/invitations` 原子提交账号链接包与 `domain_envelopes`。服务端会重新计算计划并校验每个 epoch 和收件人,不接受前端自行增加授权或选择轮换范围。`DELETE /channels/{id}/invitations/{username}` 撤销邀请、成员授权、域密钥包与该账号的设备席位,并阻止重新加入,直到再次邀请。不能移除频道主。设备面板踢出访客也会阻止该 S 重新加入。 +`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.` 子协议传递,并在推送循环中重新检查有效性。 +频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号会话(或访客终端)、频道成员关系与写入权限,不能靠客户端隐藏按钮绕过。移除账号成员才会释放成员名额;撤销账号的某台登录设备只使该设备的会话失效,不影响成员身份。WebSocket 账号 session 经 `wd.account.` 子协议传递,并在推送循环中重新检查有效性。 ### `PATCH /api/v1/channels/{id}/settings` -所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`seats` 调整成员人数上限,不能改变已有成员身份;`ttl_seconds` 的修改从现在起算。 +所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`max_members` 调整成员人数上限,不能改变已有成员身份;`ttl_seconds` 的修改从现在起算。 ### `DELETE /api/v1/channels/{id}` 所有者删除整个频道和全部密文。 @@ -112,7 +112,7 @@ raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创 ### `GET /api/v1/channels/{id}/ws?from=` WebSocket 订阅(降级链的第一级)。服务端向下推送: - 二进制消息:一条日志记录。 -- 文本消息(JSON 控制消息):`{"t":"hb","seq":N}` 心跳,`{"t":"settings"}` 设置变更,`{"t":"seats"}` 席位变更。 +- 文本消息(JSON 控制消息):`{"t":"hb","seq":N}` 心跳,`{"t":"settings"}` 设置变更,`{"t":"members"}` 成员/访客变更。 上传仍走 HTTP `POST`,便于幂等重试。 @@ -123,20 +123,19 @@ SSE 订阅(降级链的第二级)。记录以 base64url 放在 `data:` 中,`id:` WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20 秒,45 秒收不到任何消息即判定死链。重连使用指数退避(1 秒到 30 秒)加抖动,并携带 `from=last_seq+1`。 ### `GET /api/v1/channels/{id}/items/{item_id}/frames?from=` -拉取条目的帧(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}/seats`:所有者查看已加入设备(终端哈希、加密设备名、加入时间)。设备清单不等于成员人数;一个账号可有多个设备席位。 -- `PUT /api/v1/channels/{id}/seats/me`:终端上传设备名(≤256 B);enc 模式加密,raw 模式明文。 -- `PUT /api/v1/channels/{id}/seats/{endpoint_hash}/label`:所有者为指定设备更新名称;enc 模式的密文绑定目标 endpoint hash,raw 模式明文。设备也可用 `/seats/me` 更新自己的名称。 -- `DELETE /api/v1/channels/{id}/seats/{endpoint_hash}`:所有者移除该设备的席位,其活动订阅随后会被拒绝;账号成员身份和人数名额保留。撤销账号邀请/成员会移除该账号全部设备并释放成员名额。 +### 访客 +- `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`)。 ## 4. 便捷分享接口 -便捷分享是与频道并列的另一种资源,语义见 [protocol.md §15](protocol.md)。**没有 WebSocket、SSE、日志、席位和成员上传**。接口前缀 `/api/v1/shares`,只支持 enc 模式,鉴权头只用 `read` 和 `owner` 两种角色(`write` 会被拒绝为 `bad_request`)。 +便捷分享是与频道并列的另一种资源,语义见 [protocol.md §15](protocol.md)。**没有 WebSocket、SSE、日志、成员登记和成员上传**。接口前缀 `/api/v1/shares`,只支持 enc 模式,鉴权头只用 `read` 和 `owner` 两种角色(`write` 会被拒绝为 `bad_request`)。 | 接口 | 角色 | 阶段 | 说明 | |---|---|---|---| @@ -212,10 +211,10 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20 | `bad_request` | 400 | 封装格式或参数错误 | | `unauthorized` | 401 | 令牌缺失或错误 | | `forbidden` | 403 | 角色不够 | -| `not_joined` | 403 | 终端未入席,或席位已被所有者释放 | +| `not_joined` | 403 | 访客终端未加入或已被所有者移除,或账号不是该频道成员 | | `visitors_full` | 403 | 便捷分享的访客名额已满,且这个终端未登记 | | `sealed` | 409 | 便捷分享已封存,不能再修改;封存后再用所有者令牌发请求也是这个错误 | -| `seats_full` | 403 | 成员人数已满 | +| `members_full` | 403 | 成员人数已满 | | `mode_mismatch` | 403 | 帧的 ENC 标志与频道模式不符 | | `not_found` | 404 | 频道或条目不存在,或已过期 | | `conflict` | 409 | 幂等冲突 | @@ -227,7 +226,7 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20 ### 按账号恢复频道密钥 -- `GET /api/v1/accounts/channels`:只需 `X-WD-Account`,返回当前账号已加入频道的 `{channel_id,role,payload,created_at}` 密钥包;不返回其他账号或未接受邀请的密钥包,不占席位。 +- `GET /api/v1/accounts/channels`:只需 `X-WD-Account`,返回当前账号已加入频道的 `{channel_id,role,payload,created_at}` 密钥包;不返回其他账号或未接受邀请的密钥包,不占成员名额。 - `PUT /api/v1/channels/{id}/key-package`:需要该频道 owner 能力和 owner 账号,仅更新当前 owner 自己的 `{payload}`,用于补齐已创建频道的账号密钥包。 - 创建频道可携带 `key_package`(1–16384 字符 opaque 密文),与频道和 owner 成员记录原子保存。前端必须提供;底层 API 允许省略供纯协议客户端使用。 - 发送邀请时原子保存收件账号的空间密钥包、频道数据域 envelopes、默认权限和 epoch 更新;接受后仍可跨设备领取。撤销邀请/成员会删除服务端密钥包与访问记录,但无法收回对方此前保存的密钥,成员移除后的 epoch 轮换仍未实现。删除空间时清理全部密钥包。 @@ -235,4 +234,4 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20 ### 账号设备与资料 -GET /accounts/devices 返回本账号设备 {id,name,created_at,last_seen,current}。PATCH /accounts/devices/{id} 发送 {name};DELETE 撤销该设备全部登录 sessions 与频道席位,不移除账号成员。新客户端登录时携带稳定浏览器 device_id(UUID)和设备名,各设备单独记录。个人名称/头像存储在客户端加密保险库,修改后按 modifiedAt 合并,不写入服务端明文账号表。昵称不改变固定用户名或参与密钥派生。 +GET /accounts/devices 返回本账号设备 {id,name,created_at,last_seen,current}。PATCH /accounts/devices/{id} 发送 {name};DELETE 撤销该设备的全部登录 sessions,不移除账号成员。新客户端登录时携带稳定浏览器 device_id(UUID)和设备名,各设备单独记录。个人名称/头像存储在客户端加密保险库,修改后按 modifiedAt 合并,不写入服务端明文账号表。昵称不改变固定用户名或参与密钥派生。 diff --git a/docs/architecture.md b/docs/architecture.md index c2f4b46..f2a155c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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`, `label` BLOB, `joined_at`;PK(`channel_id`, `endpoint_hash`) | +| `seats` | 仅登记**访客终端**:`channel_id`, `endpoint_hash`, `joined_at`;PK(`channel_id`, `endpoint_hash`)。账号成员只在 `channel_members`,不写此表(旧库里的 `label` 列不再使用) | | `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` | 分享条目的帧状态和下载次数,独立于频道条目 | @@ -94,7 +94,7 @@ SQLite 存元数据和小帧,磁盘存大帧。 **并发模型**:写入由单个 SQLite 连接加锁串行执行(WAL 模式),读取使用独立连接,每次 `Read` 在显式事务里,看到一致的快照。订阅枢纽 `ChannelHub` 只传递「有变化」的信号,订阅者被唤醒后自己按游标读日志,所以不存在回放与实时之间漏帧的竞态,也无需为慢客户端缓冲数据。代价是目前只支持**单实例**部署。 -便捷分享与频道共用 ID 唯一性检查,但条目和帧使用独立表。分享不创建席位或日志;封存时在单个事务里清空 `owner_hash`、记录清单 id 并启动 TTL。 +便捷分享与频道共用 ID 唯一性检查,但条目和帧使用独立表。分享不登记成员或日志;封存时在单个事务里清空 `owner_hash`、记录清单 id 并启动 TTL。 后台服务每 30 秒清理:过期条目、下载达上限且宽限期已过的条目、过期频道,每次产生对应的墓碑并删除密文;每 20 轮额外清理超过 10 分钟的孤立 blob。超出 `max_items` 的淘汰和新快照取代旧快照,在追加时同步完成。 @@ -114,7 +114,7 @@ SQLite 存元数据和小帧,磁盘存大帧。 - 发送队列落在 IndexedDB,恢复网络后自动补发,依赖协议的幂等追加。 - 条目预览在沙箱 iframe 中渲染;raw 内容只当文本展示或下载。 - 下载大文件优先使用 OPFS 流式写入临时文件,认证完成后提供文件;不支持 OPFS 时回退 Blob,限制 64 MiB。 -- 首页先注册/登录;公开频道/分享链接可按规则由访客访问。创建与管理在服务端绑定账号,成员人数按账号去重,设备终端席位独立管理。所有写入都检查账号或访客设备、频道角色/成员名额/设备席位和能力权限。 +- 首页先注册/登录;公开频道/分享链接可按规则由访客访问。创建与管理在服务端绑定账号,成员人数按账号去重,设备只是账号的属性(账号设备与登录 session 在 `/accounts/devices` 管理);访客是未登录终端,单独登记。所有写入都检查账号会话或访客终端、频道角色、成员名额和能力权限。 - IndexedDB 按账号与访客分库;切换时中止后台订阅并清空内存。保险库同步私钥、资源凭据和移除墓碑;设备 S、设备标签、草稿内容、缓存、原始文件和 outbox 保留本机。新设备生成独立 S 后重新加入。 - 空间统一管理成员、权限与密钥,后端管理频道、分组和目录。资源日志仅保存内容及资源元数据;CONTROL 资源元数据记录随空间删除。 @@ -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 级文件:并行上传和断点续传 | 中途断网后续传成功,内存占用平稳 | diff --git a/docs/e2ee-boundary-comparison.md b/docs/e2ee-boundary-comparison.md index c85f3c2..88f5862 100644 --- a/docs/e2ee-boundary-comparison.md +++ b/docs/e2ee-boundary-comparison.md @@ -18,7 +18,7 @@ | 密码与数据密钥 | 账户认证不直接充当数据加密根 | 密码/可用的 Passkey PRF 解锁保险库;随机账号 ECDH 私钥解开账号 Key Envelope;频道数据域密钥独立随机生成 | 密码不直接生成资源密钥;账号恢复仍与 E2EE 身份私钥恢复耦合 | | 用户身份 | Signing Identity 与 Device Keys | 账号保险库保存 P-256 签名根密钥;本机保存不可导出的设备签名私钥,根签名设备证书由后端校验并登记 | 身份密钥基础已落地;尚未用于业务事件 | | 新设备 | 可信恢复或已有设备审批 | 用户已决定不要求旧设备审批;新设备以账号密码/支持的 Passkey PRF 解锁同一保险库私钥 | 与参考文档的设备审批模型不同;账号被攻破会暴露该账号全部空间密钥 | -| 设备分发 | 每个获授权设备各有 Key Envelope | 按 username 分发;设备只有 UUID、session、席位 S,没有设备解包公钥 | 分发对象不同 | +| 设备分发 | 每个获授权设备各有 Key Envelope | 按 username 分发;设备只有 UUID 和 session(v7 起不再有按空间的席位或终端密钥,访客除外),没有设备解包公钥 | 分发对象不同 | | Space | 主要是授权管理范围 | 成员、频道目录、日志与同步边界;不再作为频道内容根 | 业务边界已收束,仍由服务端定义 | | Channel | Chat 与 FileGroup 有独立数据域和密钥 | 每频道独立 chat/files 随机 epoch key;对象另有随机 key,由域密钥封装 | 数据域已落地,密钥包仍按账号而非设备 | | 授权链 | Owner 签名 Capability,客户端验证 | owner/member + bearer 能力令牌 + 服务端成员表,客户端没有验证签名授权链 | 服务端仍是授权判断的信任方 | @@ -74,7 +74,7 @@ 仍有几处边界值得收回或明确: -1. `SavedResource` 仍把 endpoint secret、链接等本机凭据与频道名称、席位数等服务端展示快照放在同一结构;账号保险库也同步部分快照。建议拆为本机凭据、服务端快照和传输队列,离线快照只用于只读展示。 +1. `SavedResource` 仍把 endpoint secret、链接等本机凭据与频道名称、成员人数上限等服务端展示快照放在同一结构;账号保险库也同步部分快照。建议拆为本机凭据、服务端快照和传输队列,离线快照只用于只读展示。 2. Worker 从本地 `maxDownloads` 提供上传表单初始值;服务端已校验每个 GATED 文件的下载上限,缓存仅影响表单显示,不决定授权。 3. `account.ts` 仍包含用户名输入规范化和账号本地恢复编排。服务端负责账户验证、设备 session、个人资料和保险库并发版本;浏览器只生成/持有私钥,并执行解锁加密保险库所需的本地 KDF。 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 10db092..8105f94 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,14 +1,15 @@ openapi: 3.1.0 info: title: WebDrop API - version: 6.0.0 + version: 7.0.0 servers: - url: /api/v1 tags: - name: server - name: channel - - name: seat + - name: join + - name: guest - name: frame - name: item - name: share @@ -404,7 +405,7 @@ paths: summary: 领取当前账号已加入频道的加密密钥包 responses: '200': - description: 仅返回已加入频道,不占设备席位 + description: 仅返回已加入频道 content: application/json: schema: { type: array, items: { $ref: '#/components/schemas/AccountChannel' } } @@ -443,7 +444,7 @@ paths: delete: tags: [account] security: [{ accountBearer: [] }] - summary: 撤销该设备全部 sessions 与频道席位,保留账号成员资格 + summary: 撤销该设备的全部 sessions,保留账号成员资格 responses: '204': { description: 已撤销 } '401': { $ref: '#/components/responses/Error' } @@ -702,7 +703,7 @@ paths: get: tags: [channel] summary: 读取频道信息(不占成员名额) - description: 任意角色可调用。`seats_used` 表示唯一账号成员数加匿名访客终端数,不是设备数。 + description: 任意角色可调用。`members_used` 表示唯一账号成员数加匿名访客终端数,不是设备数。 security: [{ bearer: [] }] parameters: - { $ref: '#/components/parameters/Role' } @@ -717,7 +718,7 @@ paths: delete: tags: [channel] summary: 删除整个频道及全部密文(所有者) - description: 有效的 owner 凭据即可调用,不要求 owner 终端占用席位。 + description: 有效的 owner 凭据即可调用,不要求 owner 终端登记。 security: [{ bearer: [] }] parameters: - { $ref: '#/components/parameters/RoleOwner' } @@ -733,7 +734,7 @@ paths: summary: 修改设置(所有者) description: | 字段均可选,只修改提供的字段。`ttl_seconds` 的变更从现在起算。 - 成功后向订阅者推送 `{"t":"settings"}`。有效的 owner 凭据即可调用,不要求 owner 终端占用席位。 + 成功后向订阅者推送 `{"t":"settings"}`。有效的 owner 凭据即可调用,不要求 owner 终端登记。 security: [{ bearer: [] }] parameters: - { $ref: '#/components/parameters/ChannelId' } @@ -752,35 +753,46 @@ paths: '400': { $ref: '#/components/responses/Error' } '403': { $ref: '#/components/responses/Error' } - /channels/{id}/seats: + /channels/{id}/join: parameters: - { $ref: '#/components/parameters/ChannelId' } post: - tags: [seat] - summary: 登记当前设备并加入频道 + tags: [join] + summary: 加入频道 description: | - 幂等:同一终端重复调用返回同一设备席位,不重复登记。 - 同一账号的设备不限量,账号首次加入时才占一个成员名额;成员名额已满返回 403 `seats_full`。 - 未登录访客每个终端单独计一个成员名额。读取频道信息和 owner 管理接口不占成员名额;频道内容、设备席位/设备名等操作要求终端已入席,否则 403 `not_joined`。 + 幂等。登录账号只登记成员关系:同一账号的多台设备不增加人数,也不登记终端,此后仅凭账号会话鉴权(`X-WD-Endpoint` 可省略)。 + 未登录访客登记一个终端(`X-WD-Endpoint` 必填),每个终端单独计一个成员名额。成员名额已满返回 403 `members_full`。 + 读取频道信息和 owner 管理接口不占名额;访客访问频道内容前必须已加入,否则 403 `not_joined`。 security: [{ bearer: [] }] parameters: - { $ref: '#/components/parameters/Role' } - { $ref: '#/components/parameters/Endpoint' } + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + password: { type: string, minLength: 8, maxLength: 256 } responses: '200': - description: 已入席 + description: 已加入 content: application/json: - schema: { $ref: '#/components/schemas/SeatJoined' } + schema: { $ref: '#/components/schemas/JoinResult' } '401': { $ref: '#/components/responses/Error' } '403': { $ref: '#/components/responses/Error' } '404': { $ref: '#/components/responses/Error' } + + /channels/{id}/guests: get: - tags: [seat] - summary: 查看已加入设备列表(所有者) - description: 有效的 owner 凭据即可调用,不要求 owner 终端占用席位。 + tags: [guest] + summary: 查看访客(所有者) + description: 只列未登录访客的终端。账号成员按用户管理(`/members`、`/invitations`),其设备不对外列出。 security: [{ bearer: [] }] parameters: + - { $ref: '#/components/parameters/ChannelId' } - { $ref: '#/components/parameters/RoleOwner' } responses: '200': @@ -789,69 +801,25 @@ paths: application/json: schema: type: array - items: { $ref: '#/components/schemas/Seat' } + items: { $ref: '#/components/schemas/Guest' } '403': { $ref: '#/components/responses/Error' } - /channels/{id}/seats/me: - put: - tags: [seat] - summary: 终端上传自己的加密设备名 - description: 请求体是用 `K_enc` 加密后的设备名字节,≤256 B。服务端不解析。 - security: [{ bearer: [] }] - parameters: - - { $ref: '#/components/parameters/ChannelId' } - - { $ref: '#/components/parameters/Role' } - - { $ref: '#/components/parameters/Endpoint' } - requestBody: - required: true - content: - application/octet-stream: - schema: { type: string, format: binary, maxLength: 256 } - responses: - '204': { description: 已保存 } - '403': { $ref: '#/components/responses/Error' } - '413': { $ref: '#/components/responses/Error' } - - /channels/{id}/seats/{endpointHash}/label: - put: - tags: [seat] - summary: 所有者重命名设备 - description: 请求体是设备名称(≤256 B);enc 模式以目标 endpoint hash 作为 AAD 加密,raw 模式为明文。 - security: [{ bearer: [] }] - parameters: - - { $ref: '#/components/parameters/ChannelId' } - - name: endpointHash - in: path - required: true - description: 终端标识,即 `base64url(SHA-256(S))` - schema: { $ref: '#/components/schemas/Base64Url' } - - { $ref: '#/components/parameters/RoleOwner' } - requestBody: - required: true - content: - application/octet-stream: - schema: { type: string, format: binary, maxLength: 256 } - responses: - '204': { description: 名称已更新 } - '403': { $ref: '#/components/responses/Error' } - '404': { $ref: '#/components/responses/Error' } - '413': { $ref: '#/components/responses/Error' } - /channels/{id}/seats/{endpointHash}: + /channels/{id}/guests/{endpointHash}: delete: - tags: [seat] - summary: 撤销单台设备(所有者) - description: 有效的 owner 凭据即可调用,不要求 owner 终端占用设备席位。被释放终端后续请求返回 `not_joined`;账号成员身份及人数名额保留;向订阅者推送 `{"t":"seats"}`。 + 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))` + description: 访客终端标识,即 `base64url(SHA-256(S))` schema: { $ref: '#/components/schemas/Base64Url' } - { $ref: '#/components/parameters/RoleOwner' } responses: - '204': { description: 已释放 } + '204': { description: 已移除 } '403': { $ref: '#/components/responses/Error' } '404': { $ref: '#/components/responses/Error' } @@ -950,7 +918,7 @@ paths: **服务端 → 客户端** - 二进制消息:一条日志记录(`seq ‖ kind ‖ body`)。 - - 文本消息(JSON):`{"t":"hb","seq":N}` 心跳(每 20 秒);`{"t":"settings"}` 设置变更;`{"t":"seats"}` 席位变更。 + - 文本消息(JSON):`{"t":"hb","seq":N}` 心跳(每 20 秒);`{"t":"settings"}` 设置变更;`{"t":"members"}` 成员/访客变更。 客户端 45 秒收不到任何消息应判定死链,以指数退避(1~30 秒)加抖动重连,并带 `from=last_seq+1`。 上传不走 WebSocket,仍用 `POST /frames`。 @@ -1294,8 +1262,8 @@ components: Endpoint: name: X-WD-Endpoint in: header - required: true - description: 终端密钥 `S` 的 base64url。每个频道每个终端各生成一个,仅存本地。 + required: false + description: 终端密钥 `S` 的 base64url,仅存本地。只有未登录访客必填;登录账号以 `X-WD-Account` 会话鉴权,可省略。 schema: { $ref: '#/components/schemas/Base64Url' } ShareEndpoint: name: X-WD-Endpoint @@ -1368,7 +1336,7 @@ components: - unauthorized - forbidden - not_joined - - seats_full + - members_full - mode_mismatch - not_found - conflict @@ -1439,11 +1407,11 @@ components: ChannelInfo: $ref: '../contracts/api.schemas.json#/$defs/ChannelInfo' - SeatJoined: - $ref: '../contracts/api.schemas.json#/$defs/SeatJoined' + JoinResult: + $ref: '../contracts/api.schemas.json#/$defs/JoinResult' - Seat: - $ref: '../contracts/api.schemas.json#/$defs/SeatInfo' + Guest: + $ref: '../contracts/api.schemas.json#/$defs/GuestInfo' AppendResult: $ref: '../contracts/api.schemas.json#/$defs/AppendResult' diff --git a/docs/protocol.md b/docs/protocol.md index 55f24a7..635786c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -25,7 +25,7 @@ | `K` | 32 B | 空间/临时频道访问凭据;新工作区内容加密不使用它,可选能力链接使用 fragment | | `A` | 32 B | 管理密钥,独立随机,只存在于所有者链接 | | `W` | 32 B | 写密钥,仅 raw 模式且成员可上传时 | -| `S` | 32 B | 终端密钥,每个频道每个终端各生成一个,仅存本地 | +| `S` | 32 B | 访客终端密钥,每个频道每个访客终端各生成一个,仅存本地;登录账号不需要 | ## 2. 模式与链接 @@ -130,7 +130,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ... | `mime` | 可选 | | `size` | 可选,内容总字节数(流式时可省略) | | `ts` | 毫秒时间戳,仅供参考,排序以 seq 为准 | -| `dev` | 可选,设备名 | +| `dev` | 已弃用(v7 起客户端不再写入,读取时忽略);发送者以服务端记录的账号为准 | | `thread_id` | 可选,频道 thread ID | | `folder_id` | 可选,文件的虚拟文件夹 ID | @@ -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 条目的帧允许乱序到达(并行上传),条目拉取只返回从起点开始连续的帧。 @@ -175,16 +175,15 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ... 带 SNAPSHOT 的条目取代频道中之前的快照条目(最后写入者胜)。快照必须是**非 GATED 的单帧**(整份状态不超过一帧,约 1 MiB),所以新快照一被接受,服务端就在同一个事务里删除旧快照并产生一条 `reason = 4` 的 tombstone。一个频道在 v1 里只有一个快照槽,多文档请使用多个频道。 -## 10. 终端与成员人数 +## 10. 用户、设备与成员人数 -- 终端在本地为每个频道生成随机 `S`,请求中带 `S`;服务端保存 `SHA-256(S)` 作为终端标识。 -- 终端通过显式的加入请求(`POST .../seats`)建立设备席位;创建者的当前终端在创建时自动入席。席位用于设备级认证、命名和撤销,不限制同一账号可加入的设备数量。未入席的终端访问频道返回 `not_joined`。 -- 频道设置字段 `seats` 保留原线格式名称,但 v5 起表示**最大成员人数**。owner 和每个唯一账号各计一人,同一账号的额外设备不增加人数。成员人数达到上限时,新的账号成员加入返回 `seats_full`。 -- 未登录访客没有可跨设备识别的账号身份,每个访客终端按一个访客成员计入上限;因此访客在多台设备加入会占多个名额。若希望严格按自然人去重,应要求加入者登录账号。 -- 不同频道使用不同的 `S`,避免服务端把同一设备关联起来。 -- 终端可以加密一个设备名上传,所有者能看到设备列表和设备名,并可以释放单个设备席位。 -- 终端可更新自己的名称;所有者也可重命名任意设备席位。enc 模式重命名仍使用同一份加密设备标签,并将 AAD 绑定目标 endpoint hash;raw 模式标签仍为明文。撤销设备席位后,该终端不能继续读取频道,但账号成员身份和其人数名额保留;撤销账号成员/邀请才释放该账号名额。 -- **设备名格式(enc)**:`iv(12 B 随机) ‖ AES-256-GCM(K_label, utf8 名称) ‖ tag(16 B)`,AAD = `"WD1L"`(4 B ASCII)‖ `channel_id` ‖ `endpoint_hash`(32 B,即 `SHA-256(S)`)。总长不超过 256 B,所以名称最多 228 字节 UTF-8。使用派生子密钥 `K_label` 而不是直接用 `K_enc`,避免同一密钥同时用于 HKDF 和 AES-GCM;AAD 绑定终端,服务端无法把一个终端的设备名挪到另一个席位上。raw 模式下设备名是明文 UTF-8。 +- **成员是用户(账号)**。设备只是账号的属性:同一账号可在任意多台设备登录,只占一个成员名额;账号成员的请求凭有效的账号 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` 才算续传。 +- 在线状态由服务端按账号统计(`members` 接口的 `online`、`devices`、`online_devices`),只来自内存里的订阅登记,不写库。 +- **已移除**:每终端的加密设备名(`K_label`、AAD `WD1L`、`PUT .../seats/me` 与 `.../seats/{hash}/label`)和设备席位清单。设备名只在账号设备管理里存在(`/accounts/devices`)。`K_label` 的派生仍保留在 §3,为已发布的测试向量保持稳定,但不再被使用。 ## 11. 创建与验证码 @@ -205,7 +204,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ... | 设置 | 默认 | 范围 | |---|---|---| | `name` | 无 | 可选,最多 48 个字符;所有频道成员和服务端可见 | -| `seats`(v5 起为成员人数上限) | 2 | 1..64(服务端可配置上限);每个账号计一人,其多设备不增加人数 | +| `max_members`(v7 前叫 `seats`,最大成员人数) | 2 | 1..64(服务端可配置上限);每个账号计一人,其多设备不增加人数 | | `members_can_upload` | true | | | `delete_policy` | `own` | `owner_only` / `own` / `anyone` | | `ttl_seconds` | 86400 | 最长 604800,每个条目从创建起算 | @@ -346,7 +345,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ... ## 17. 账号凭据与频道域密钥 -空间访问凭据用于服务端资源/终端鉴权,不是频道内容根。创建者用自己的账号 P-256 公钥封装 Owner 凭据,随空间、默认频道、Owner 成员记录原子保存。账号接受空间邀请后,可凭账号 session 从 `/accounts/channels` 取回自己的空间凭据;设备席位仍在实际打开空间时建立。 +空间访问凭据用于服务端资源/终端鉴权,不是频道内容根。创建者用自己的账号 P-256 公钥封装 Owner 凭据,随空间、默认频道、Owner 成员记录原子保存。账号接受空间邀请后,可凭账号 session 从 `/accounts/channels` 取回自己的空间凭据;账号成员不登记终端,实际打开空间时只确认成员关系(`POST .../join` 幂等)。 内容加密密钥由浏览器生成。每个频道各有独立 `chat` 与 `files` 域,每域从 epoch 1 开始使用随机 32 B key。域信封沿用账号 ECDH、HKDF、AES-GCM,AAD 绑定账号、空间 ID、频道 ID、域名与 epoch。服务端保存 opaque 密文,只向通过频道/域读取授权的账号返回信封。每条消息、文件和加密发布元数据使用独立随机 32 B 对象密钥;对象密钥由域 key 的 AES-GCM 封装,AAD 绑定空间、频道、域、epoch 和 item ID。 diff --git a/docs/threat-model.md b/docs/threat-model.md index 8f97dc6..b72fa00 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -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、成员人数和设备席位;加密选项不会绕开权限 | +| 绕过频道加入规则 | 服务端检查 owner/member 角色、邀请、密码、账号 session(访客为终端登记)、成员人数;加密选项不会绕开权限 | | 匿名滥用与 DoS | 创建时的 Cap 验证码(工作量证明由 Cap 提供)、限流、频道配额、条目数和字节数上限 | ## 5. 前端信任根(残余风险) @@ -102,6 +102,6 @@ ## 8. 账号设备与本机缓存 -同一账号的各设备拥有独立 sessions 和频道 S,但在频道成员上限中只计一个账号成员。撤销设备会删除该设备 sessions 与频道席位;不会移除账号成员或释放其成员名额,也不会影响其他设备。撤销不能擦除已下载数据、已持有的频道 K 或被复制的账号私钥。当前未实现密钥轮换,不声称前向保密。未登录访客无法跨设备去重,每个访客终端单独计入人数上限。 +同一账号的各设备拥有独立 sessions,但在频道成员上限中只计一个账号成员,成员请求凭账号 session 鉴权。撤销设备会删除该设备 sessions;不会移除账号成员或释放其成员名额,也不会影响其他设备。撤销不能擦除已下载数据、已持有的频道 K 或被复制的账号私钥。当前未实现密钥轮换,不声称前向保密。未登录访客无法跨设备去重,每个访客终端单独计入人数上限。 本机记住登录允许本浏览器使用者访问账号,退出时删除自动恢复凭据。OPFS 存储解密后临时文件,正常退出账号时清理;浏览器崩溃可能留下本机缓存,它不属于服务端密文。 diff --git a/server/scripts/smoke.ps1 b/server/scripts/smoke.ps1 index 2465a9e..bbba5ab 100644 --- a/server/scripts/smoke.ps1 +++ b/server/scripts/smoke.ps1 @@ -46,12 +46,12 @@ try { $ownerH = Headers 'owner' $ownerTok $ownerSecret $created = Invoke-RestMethod "$base/api/v1/channels" -Method Post -Body $body -ContentType 'application/json' -Headers $ownerH - Check ($created.id -eq (B64 $id) -and $created.seats_used -eq 1) '创建频道成功' + Check ($created.id -eq (B64 $id) -and $created.members_used -eq 1) '创建频道成功' $cid = B64 $id $memberH = Headers 'write' $writeTok $memberSecret - $joined = Invoke-RestMethod "$base/api/v1/channels/$cid/seats" -Method Post -Headers $memberH - Check ($joined.seats_used -eq 2) '成员加入' + $joined = Invoke-RestMethod "$base/api/v1/channels/$cid/join" -Method Post -Headers $memberH + Check ($joined.members_used -eq 2) '成员加入' # enc 帧:负载 = 1024 档位 + 16 标签 $item = Rand 16 diff --git a/server/scripts/smoke.py b/server/scripts/smoke.py index e233a19..0a5f918 100644 --- a/server/scripts/smoke.py +++ b/server/scripts/smoke.py @@ -71,8 +71,8 @@ try: check(status == 201, "创建频道") ch = b64(cid) - status, body = call("POST", f"/api/v1/channels/{ch}/seats", member_h) - check(status == 200 and json.loads(body)["seats_used"] == 2, "成员加入") + status, body = call("POST", f"/api/v1/channels/{ch}/join", member_h) + check(status == 200 and json.loads(body)["members_used"] == 2, "成员加入") item = os.urandom(16) f = frame(item, 0, 9, 1040, 0x42) diff --git a/server/src/WebDrop.Api/ApiContract.cs b/server/src/WebDrop.Api/ApiContract.cs index 28f48e4..bd3da84 100644 --- a/server/src/WebDrop.Api/ApiContract.cs +++ b/server/src/WebDrop.Api/ApiContract.cs @@ -2,7 +2,8 @@ namespace WebDrop.Api; public static class ApiContract { - // v5 changes channel capacity from per-endpoint devices to account members. + // v5: 频道容量按账号成员计;v7: seats 改名 max_members/members_used/members_full, + // 去掉设备名与设备清单接口,访客改走 /guests。设备只是账号的属性。 // Keep this aligned with contracts/api-contract.json and docs/openapi.yaml. - public const string Version = "6.0.0"; + public const string Version = "7.0.0"; } diff --git a/server/src/WebDrop.Api/ApiJson.cs b/server/src/WebDrop.Api/ApiJson.cs index 323ca5b..49548e9 100644 --- a/server/src/WebDrop.Api/ApiJson.cs +++ b/server/src/WebDrop.Api/ApiJson.cs @@ -33,7 +33,7 @@ public sealed class DefaultsBody } public sealed class SettingChoicesBody { - [JsonPropertyName("seats")] public required int[] Seats { get; init; } + [JsonPropertyName("max_members")] public required int[] MaxMembers { get; init; } [JsonPropertyName("ttl_seconds")] public required int[] TtlSeconds { get; init; } [JsonPropertyName("file_max_downloads")] public int[] FileMaxDownloads { get; init; } = ServerSettingChoices.FileMaxDownloads; [JsonPropertyName("max_visitors")] public required int[] MaxVisitors { get; init; } @@ -61,7 +61,7 @@ public sealed class LimitsBody [JsonPropertyName("max_channel_bytes")] public required long MaxChannelBytes { get; init; } [JsonPropertyName("max_workspace_bytes")] public required long MaxWorkspaceBytes { get; init; } [JsonPropertyName("max_workspace_channel_bytes")] public required long MaxWorkspaceChannelBytes { get; init; } - [JsonPropertyName("max_seats")] public required int MaxSeats { get; init; } + [JsonPropertyName("max_members")] public required int MaxMembers { get; init; } [JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; } [JsonPropertyName("min_ttl_seconds")] public required int MinTtlSeconds { get; init; } [JsonPropertyName("max_items")] public required int MaxItems { get; init; } @@ -152,6 +152,13 @@ public sealed class MemberBody [JsonPropertyName("username")] public required string Username { get; init; } [JsonPropertyName("role")] public required string Role { get; init; } [JsonPropertyName("joined_at")] public required long JoinedAt { get; init; } + + /// 该账号此刻在本频道有至少一个活动订阅(WebSocket/SSE)。 + [JsonPropertyName("online")] public required bool Online { get; init; } + + /// 该账号在本频道登记过的设备(终端)数;设备只是账号的属性,不是独立成员。 + [JsonPropertyName("devices")] public required int Devices { get; init; } + [JsonPropertyName("online_devices")] public required int OnlineDevices { get; init; } } public sealed class AccountProfileRequest @@ -227,8 +234,8 @@ public sealed class WorkspaceGroupCreatedBody [JsonSerializable(typeof(FinishShareRequest))] [JsonSerializable(typeof(CreateChannelRequest))] [JsonSerializable(typeof(ChannelSettingsPatch))] -[JsonSerializable(typeof(SeatJoined))] -[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(JoinResult))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(AppendResult))] [JsonSerializable(typeof(AccountRegisterRequest))] [JsonSerializable(typeof(AccountDeviceSigningRequest))] diff --git a/server/src/WebDrop.Api/ChannelHub.cs b/server/src/WebDrop.Api/ChannelHub.cs index 5bb5985..1dec518 100644 --- a/server/src/WebDrop.Api/ChannelHub.cs +++ b/server/src/WebDrop.Api/ChannelHub.cs @@ -12,6 +12,9 @@ public sealed class ChannelHub : IChannelEvents private readonly object _gate = new(); private readonly Dictionary _signals = []; + // 在线状态:频道 -> 账号 -> 账号设备 -> 活动订阅数。只记账号,访客没有可跨设备识别的身份,不参与。 + private readonly Dictionary>> _presence = []; + public Subscription Subscribe(Id16 id) { lock (_gate) @@ -22,9 +25,57 @@ public sealed class ChannelHub : IChannelEvents } } + /// 登记一个活动订阅,释放返回值即下线。只供成员接口读取,不向订阅流推送,以免改变流的线格式。 + public IDisposable Connected(Id16 id, string? account, string deviceKey) + { + if (account is null) return NoPresence.Instance; + string endpoint = deviceKey; + lock (_gate) + { + if (!_presence.TryGetValue(id, out var accounts)) _presence[id] = accounts = []; + if (!accounts.TryGetValue(account, out var endpoints)) accounts[account] = endpoints = []; + endpoints[endpoint] = endpoints.GetValueOrDefault(endpoint) + 1; + } + return new Connection(() => Disconnected(id, account, endpoint)); + } + + private void Disconnected(Id16 id, string account, string endpoint) + { + lock (_gate) + { + if (_presence.TryGetValue(id, out var accounts) && accounts.TryGetValue(account, out var endpoints) && endpoints.TryGetValue(endpoint, out int count)) + { + if (count <= 1) endpoints.Remove(endpoint); else endpoints[endpoint] = count - 1; + if (endpoints.Count == 0) accounts.Remove(account); + if (accounts.Count == 0) _presence.Remove(id); + } + } + } + + public (bool Online, int Devices) Presence(Id16 id, string account) + { + lock (_gate) + { + return _presence.TryGetValue(id, out var accounts) && accounts.TryGetValue(account, out var endpoints) + ? (endpoints.Count > 0, endpoints.Count) : (false, 0); + } + } + + private sealed class Connection(Action release) : IDisposable + { + private Action? _release = release; + 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 SeatsChanged(Id16 channelId) => Publish(channelId, s => s.SeatsVersion++); + public void MembersChanged(Id16 channelId) => Publish(channelId, s => s.MembersVersion++); public void ChannelDeleted(Id16 channelId) => Publish(channelId, s => s.Deleted = true); private void Publish(Id16 id, Action mutate) @@ -53,7 +104,7 @@ public sealed class ChannelHub : IChannelEvents public int Refs; public long Seq; public int SettingsVersion; - public int SeatsVersion; + public int MembersVersion; public bool Deleted; public Task Changed => _tcs.Task; @@ -91,9 +142,9 @@ public sealed class ChannelHub : IChannelEvents get { lock (_hub._gate) return _signal.Changed; } } - public (int Settings, int Seats, bool Deleted) State + public (int Settings, int MaxMembers, bool Deleted) State { - get { lock (_hub._gate) return (_signal.SettingsVersion, _signal.SeatsVersion, _signal.Deleted); } + get { lock (_hub._gate) return (_signal.SettingsVersion, _signal.MembersVersion, _signal.Deleted); } } public void Dispose() diff --git a/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs b/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs index 96f9be4..3e0ca17 100644 --- a/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs +++ b/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs @@ -47,8 +47,18 @@ public static partial class Endpoints if (accountStore.GetPublicKey(username) is null) throw new WebDropException(ErrorCode.NotFound); return Json(svc.InvitationPlan(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), username), ApiJson.Default.InvitationCryptoPlan); }); - api.MapGet("/channels/{id}/members", (string id, HttpContext ctx, ChannelService svc) => - Json(svc.Members(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true)).Select(m => new MemberBody { Username = m.Username, Role = m.Role, JoinedAt = m.JoinedAt }).ToList(), ApiJson.Default.ListMemberBody)); + api.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 (online, onlineDevices) = hub.Presence(channelId, member.Username); + // 设备是账号的属性:总数取账号的登录设备,在线数取该账号在本频道有订阅的设备 + return new MemberBody { Username = member.Username, Role = member.Role, JoinedAt = member.JoinedAt, + Online = online, Devices = Math.Max(accounts.Devices(member.Username, null).Count, onlineDevices), OnlineDevices = onlineDevices }; + }).ToList(); + return Json(members, ApiJson.Default.ListMemberBody); + }); api.MapPost("/channels/{id}/invitations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.InvitationRequest, ct); diff --git a/server/src/WebDrop.Api/Endpoints.cs b/server/src/WebDrop.Api/Endpoints.cs index 26b402b..6a4cae1 100644 --- a/server/src/WebDrop.Api/Endpoints.cs +++ b/server/src/WebDrop.Api/Endpoints.cs @@ -32,7 +32,7 @@ public static partial class Endpoints Defaults = new DefaultsBody { Channel = CreationDefaults.Channel(limits), Share = CreationDefaults.Share(limits) }, Choices = new SettingChoicesBody { - Seats = new[] { 2, 3, 5, 10, limits.MaxSeats }.Where(n => n >= 1 && n <= limits.MaxSeats).Distinct().Order().ToArray(), + MaxMembers = new[] { 2, 3, 5, 10, limits.MaxMembers }.Where(n => n >= 1 && n <= limits.MaxMembers).Distinct().Order().ToArray(), TtlSeconds = new[] { 3600, 86_400, 604_800, limits.MinTtlSeconds, limits.MaxTtlSeconds, CreationDefaults.Channel(limits).TtlSeconds } .Where(n => n >= limits.MinTtlSeconds && n <= limits.MaxTtlSeconds).Distinct().Order().ToArray(), MaxVisitors = new[] { 0, 2, 5, 10, 20, limits.MaxVisitors }.Where(n => n <= limits.MaxVisitors).Distinct().Order().ToArray(), @@ -44,7 +44,7 @@ public static partial class Endpoints MaxChannelBytes = limits.MaxChannelBytes, MaxWorkspaceBytes = limits.MaxWorkspaceBytes, MaxWorkspaceChannelBytes = limits.MaxWorkspaceChannelBytes, - MaxSeats = limits.MaxSeats, + MaxMembers = limits.MaxMembers, MaxTtlSeconds = limits.MaxTtlSeconds, MinTtlSeconds = limits.MinTtlSeconds, MaxItems = limits.MaxItemsLimit, @@ -85,42 +85,24 @@ public static partial class Endpoints return Results.NoContent(); }); - // ───────── 席位 ───────── + // ───────── 加入 ───────── - api.MapPost("/channels/{id}/seats", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => + 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.SeatJoined); + return Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), request.Password), ApiJson.Default.JoinResult); }); MapChannelAccess(api, accounts); - api.MapGet("/channels/{id}/seats", (string id, HttpContext ctx, ChannelService svc) => - { - var seats = svc.ListSeats(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false)); - return Json([.. seats], ApiJson.Default.ListSeatInfo); - }); + // 访客是未登录的终端;账号成员只按用户管理(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.MapPut("/channels/{id}/seats/me", async (string id, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) => - { - var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true); - byte[] label = await ReadBody(ctx.Request, limits.MaxSeatLabelBytes, ct); - svc.SetSeatLabel(ParseId(id), creds, label); - return Results.NoContent(); - }); - - api.MapPut("/channels/{id}/seats/{endpointHash}/label", async (string id, string endpointHash, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) => + 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); - byte[] label = await ReadBody(ctx.Request, limits.MaxSeatLabelBytes, ct); - svc.SetSeatLabelForEndpoint(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash, label); - return Results.NoContent(); - }); - - api.MapDelete("/channels/{id}/seats/{endpointHash}", (string id, string endpointHash, HttpContext ctx, ChannelService svc) => - { - if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest); - svc.ReleaseSeat(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash); + svc.ReleaseGuest(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash); return Results.NoContent(); }); diff --git a/server/src/WebDrop.Api/LogStreamer.cs b/server/src/WebDrop.Api/LogStreamer.cs index ca0abca..f593959 100644 --- a/server/src/WebDrop.Api/LogStreamer.cs +++ b/server/src/WebDrop.Api/LogStreamer.cs @@ -29,6 +29,7 @@ public static class LogStreamer { using var sub = hub.Subscribe(id); ILogSink? sink = null; + IDisposable? presence = null; long cursor = from; var (settingsV, seatsV, _) = sub.State; @@ -40,6 +41,8 @@ public static class LogStreamer var changed = sub.Changed; // 先取任务再读状态:读取期间的发布不会丢 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)); foreach (var record in page.Records) { @@ -56,7 +59,7 @@ public static class LogStreamer if (seatsNow != seatsV) { seatsV = seatsNow; - await sink.SendControlAsync("""{"t":"seats"}""", ct).ConfigureAwait(false); + await sink.SendControlAsync("""{"t":"members"}""", ct).ConfigureAwait(false); } if (deleted) throw new WebDropException(ErrorCode.NotFound); @@ -80,6 +83,10 @@ public static class LogStreamer { await sink.FailAsync(e.Code, CancellationToken.None).ConfigureAwait(false); } + finally + { + presence?.Dispose(); + } } } diff --git a/server/src/WebDrop.Api/RequestAuth.cs b/server/src/WebDrop.Api/RequestAuth.cs index 4b50730..42c3343 100644 --- a/server/src/WebDrop.Api/RequestAuth.cs +++ b/server/src/WebDrop.Api/RequestAuth.cs @@ -35,7 +35,8 @@ 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, deviceId, accountName is not null); + // 账号以会话鉴权,终端密钥只对未登录访客是必需的 + return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint && accountName is null), accountName, deviceId, accountName is not null); } /// @@ -68,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: true), accountName, deviceId, accountName is not null); + return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: accountName is null), accountName, deviceId, accountName is not null); } public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true); diff --git a/server/src/WebDrop.Core/Abstractions.cs b/server/src/WebDrop.Core/Abstractions.cs index 0f35185..f991a14 100644 --- a/server/src/WebDrop.Core/Abstractions.cs +++ b/server/src/WebDrop.Core/Abstractions.cs @@ -42,7 +42,7 @@ public interface IChannelEvents { void Advanced(Id16 channelId, long seq); void SettingsChanged(Id16 channelId); - void SeatsChanged(Id16 channelId); + void MembersChanged(Id16 channelId); void ChannelDeleted(Id16 channelId); } @@ -50,7 +50,7 @@ public sealed class NullChannelEvents : IChannelEvents { public void Advanced(Id16 channelId, long seq) { } public void SettingsChanged(Id16 channelId) { } - public void SeatsChanged(Id16 channelId) { } + public void MembersChanged(Id16 channelId) { } public void ChannelDeleted(Id16 channelId) { } } @@ -130,7 +130,6 @@ public interface IResourceTx int CountMembers(Id16 channelId); void InsertSeat(Id16 channelId, Seat seat); bool DeleteSeat(Id16 channelId, ReadOnlySpan endpointHash); - void SetSeatLabel(Id16 channelId, ReadOnlySpan endpointHash, byte[] label); IReadOnlyList ListSeats(Id16 channelId); // 条目 diff --git a/server/src/WebDrop.Core/ChannelAdmission.cs b/server/src/WebDrop.Core/ChannelAdmission.cs index 09ea18b..e411fc1 100644 --- a/server/src/WebDrop.Core/ChannelAdmission.cs +++ b/server/src/WebDrop.Core/ChannelAdmission.cs @@ -56,7 +56,7 @@ public sealed partial class ChannelService tx.DenyAccount(id, username); return 0; }); - events.SeatsChanged(id); + events.MembersChanged(id); } public IReadOnlyList Members(Id16 id, Credentials creds) => store.Read(tx => @@ -72,7 +72,7 @@ public sealed partial class ChannelService public void ReleaseAccountDevice(string username, string deviceId) { var channels = store.Write(tx => tx.DeleteDeviceSeats(username, deviceId)); - foreach (var id in channels) events.SeatsChanged(id); + foreach (var id in channels) events.MembersChanged(id); } public void SaveOwnerKeyPackage(Id16 id, Credentials creds, string payload) diff --git a/server/src/WebDrop.Core/ChannelService.cs b/server/src/WebDrop.Core/ChannelService.cs index e323534..c2e52d5 100644 --- a/server/src/WebDrop.Core/ChannelService.cs +++ b/server/src/WebDrop.Core/ChannelService.cs @@ -59,7 +59,6 @@ public sealed partial class ChannelService( tx.PutWorkspace(id, NewWorkspace(creatorAccount, now)); tx.PutChannelMember(id, new ChannelMember(creatorAccount, "owner", now)); if (req.KeyPackage is not null) tx.PutKeyPackage(id, creatorAccount, req.KeyPackage, now); - tx.InsertSeat(id, new Seat(endpointHash, now, null, creatorAccount, accountDeviceId)); return 0; }); return ToInfo(channel, 1); @@ -112,92 +111,60 @@ public sealed partial class ChannelService( // ───────────────────────── 席位 ───────────────────────── - public SeatJoined Join(Id16 id, Credentials creds, string? password = null) + /// + /// 加入频道。账号成员只登记成员关系(同一账号的多台设备不增加人数,也不登记终端); + /// 未登录访客登记终端,每个终端按一个访客成员计入上限。 + /// + 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 existing = tx.GetSeat(id, principal.EndpointHash); - if (existing?.AccountName is not null && existing.AccountName != creds.AccountName) throw new WebDropException(ErrorCode.AccountRequired); - if (existing?.AccountDeviceId is not null && existing.AccountDeviceId != creds.AccountDeviceId) throw new WebDropException(ErrorCode.AccountRequired); int membersUsed = tx.CountMembers(id); - bool accountAlreadyMember = creds.AccountName is not null && tx.GetChannelMember(id, creds.AccountName) is not null; - if (existing is not null) + JoinResult Result(long joinedAt, int used) => new() { JoinedAt = joinedAt, MembersUsed = used, MembersMax = channel.Settings.MaxMembers }; + + if (creds.AccountName is not null) { - if (existing.AccountName is not null && existing.AccountDeviceId is null && creds.AccountDeviceId is not null) - tx.BindSeatDevice(id, principal.EndpointHash, creds.AccountDeviceId); - return (new SeatJoined { EndpointHash = Codec.Encode(existing.EndpointHash), JoinedAt = existing.JoinedAt, SeatsUsed = membersUsed, SeatsMax = channel.Settings.Seats }, false); + 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); CheckAdmission(tx, channel, creds, password); - - bool newMember = creds.AccountName is null || !accountAlreadyMember; - if (newMember && membersUsed >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull); + if (membersUsed >= channel.Settings.MaxMembers) throw new WebDropException(ErrorCode.MembersFull); long now = clock.NowMs; - tx.InsertSeat(id, new Seat(principal.EndpointHash, now, null, creds.AccountName, creds.AccountDeviceId)); - if (creds.AccountName is not null) tx.PutChannelMember(id, new ChannelMember(creds.AccountName, "member", now)); - return (new SeatJoined { EndpointHash = Codec.Encode(principal.EndpointHash), JoinedAt = now, SeatsUsed = membersUsed + (newMember ? 1 : 0), SeatsMax = channel.Settings.Seats }, true); + tx.InsertSeat(id, new Seat(principal.EndpointHash, now)); + return (Result(now, membersUsed + 1), true); }); - if (isNew) events.SeatsChanged(id); + if (isNew) events.MembersChanged(id); return joined; } - public IReadOnlyList ListSeats(Id16 id, Credentials creds) => store.Read(tx => + /// 所有者查看访客(未登录终端)。账号成员按用户管理,其设备不对外列出。 + public IReadOnlyList ListGuests(Id16 id, Credentials creds) => store.Read(tx => { var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal); - return (IReadOnlyList)tx.ListSeats(id).Select(s => new SeatInfo - { - EndpointHash = Codec.Encode(s.EndpointHash), - JoinedAt = s.JoinedAt, - Label = s.Label is null ? null : Codec.Encode(s.Label), - AccountName = s.AccountName, - }).ToList(); + return (IReadOnlyList)tx.ListSeats(id).Where(s => s.AccountName is null) + .Select(s => new GuestInfo { EndpointHash = Codec.Encode(s.EndpointHash), JoinedAt = s.JoinedAt }).ToList(); }); - public void SetSeatLabel(Id16 id, Credentials creds, byte[] label) - { - ValidateSeatLabel(label); - store.Write(tx => - { - var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); - tx.SetSeatLabel(id, principal.EndpointHash, label); - return 0; - }); - events.SeatsChanged(id); - } - - public void SetSeatLabelForEndpoint(Id16 id, Credentials creds, byte[] endpointHash, byte[] label) - { - ValidateSeatLabel(label); - store.Write(tx => - { - var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); - RequireOwner(principal); - if (tx.GetSeat(id, endpointHash) is null) throw new WebDropException(ErrorCode.NotFound); - tx.SetSeatLabel(id, endpointHash, label); - return 0; - }); - events.SeatsChanged(id); - } - - private void ValidateSeatLabel(byte[] label) - { - if (label.Length == 0) throw new WebDropException(ErrorCode.BadRequest); - if (label.Length > limits.MaxSeatLabelBytes) throw new WebDropException(ErrorCode.TooLarge); - } - - public void ReleaseSeat(Id16 id, Credentials creds, byte[] endpointHash) + /// 所有者移除一个访客终端并禁止它再加入。账号成员改用撤销邀请(RevokeInvitation)。 + public void ReleaseGuest(Id16 id, Credentials creds, byte[] endpointHash) { store.Write(tx => { var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal); - if (!tx.DeleteSeat(id, endpointHash)) throw new WebDropException(ErrorCode.NotFound); + 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.SeatsChanged(id); + events.MembersChanged(id); } // ───────────────────────── 追加帧 ───────────────────────── @@ -505,7 +472,7 @@ public sealed partial class ChannelService( if (item.ExpiresAt <= now) throw new WebDropException(ErrorCode.Gone); if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline)) throw new WebDropException(ErrorCode.Gone); - if (item.IsGated && fromIndex > 0 && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash)) + if (item.IsGated && fromIndex > 0 && !tx.HasDownloadGrant(id, itemId, principal.GrantKey)) throw new WebDropException(ErrorCode.Gone); if (fromIndex == 0 && item.IsGated) @@ -518,7 +485,7 @@ public sealed partial class ChannelService( ? now + limits.DownloadGraceSeconds * 1000L : item.CloseDeadline; tx.UpdateItem(item with { Downloads = downloads, CloseDeadline = closeAt }); - tx.AddDownloadGrant(id, itemId, principal.EndpointHash); + tx.AddDownloadGrant(id, itemId, principal.GrantKey); } return 0; }); @@ -536,7 +503,7 @@ public sealed partial class ChannelService( var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound); if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden); if (item.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.Gone); - if (item.IsGated && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash)) + if (item.IsGated && !tx.HasDownloadGrant(id, itemId, principal.GrantKey)) throw new WebDropException(ErrorCode.Gone); if (item.CloseDeadline is { } d && clock.NowMs >= d) throw new WebDropException(ErrorCode.Gone); @@ -712,12 +679,12 @@ public sealed partial class ChannelService( var role = VerifyRole(channel, creds); var member = creds.AccountName is null ? null : tx.GetChannelMember(id, creds.AccountName); if (role == Role.Owner && member?.Role != "owner") throw new WebDropException(ErrorCode.AccountRequired); - if (seat == SeatRule.Required) + 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 && (endpoint.AccountName != creds.AccountName || member is null)) throw new WebDropException(ErrorCode.AccountRequired); - if (endpoint.AccountDeviceId is not null && endpoint.AccountDeviceId != creds.AccountDeviceId) throw new WebDropException(ErrorCode.AccountRequired); + if (endpoint.AccountName is not null) throw new WebDropException(ErrorCode.AccountRequired); } return (channel, new Principal(role, creds.EndpointHash, creds.AccountName)); } @@ -755,7 +722,7 @@ public sealed partial class ChannelService( PasswordRequired = channel.JoinPasswordHash is not null, Mode = channel.Mode, Settings = channel.Settings, - SeatsUsed = membersUsed, + MembersUsed = membersUsed, OldestSeq = channel.CompactedThrough + 1, LastSeq = channel.LastSeq, CreatedAt = channel.CreatedAt, diff --git a/server/src/WebDrop.Core/Dtos.cs b/server/src/WebDrop.Core/Dtos.cs index c526234..01a7c4e 100644 --- a/server/src/WebDrop.Core/Dtos.cs +++ b/server/src/WebDrop.Core/Dtos.cs @@ -60,7 +60,7 @@ public sealed class ChannelInfo [JsonPropertyName("mode")] public required ChannelMode Mode { get; init; } [JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; } /// v5: 账号成员数加匿名访客终端数,不统计账号设备数。 - [JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; } + [JsonPropertyName("members_used")] public required int MembersUsed { get; init; } /// 从该 seq 起的续传有效;0 < from < oldest_seq 的续传会得到 410,需从 0 全量同步。 [JsonPropertyName("oldest_seq")] public required long OldestSeq { get; init; } @@ -70,21 +70,19 @@ public sealed class ChannelInfo [JsonPropertyName("expires_at")] public required long ExpiresAt { get; init; } } -public sealed class SeatJoined +public sealed class JoinResult { - [JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; } [JsonPropertyName("joined_at")] public required long JoinedAt { get; init; } /// 加入后的成员人数,同一账号的其他终端不增加此值。 - [JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; } - [JsonPropertyName("seats_max")] public required int SeatsMax { get; init; } + [JsonPropertyName("members_used")] public required int MembersUsed { get; init; } + [JsonPropertyName("members_max")] public required int MembersMax { get; init; } } -public sealed class SeatInfo +/// 未登录访客的一次加入(终端)。账号成员按用户管理,不在这里出现。 +public sealed class GuestInfo { - [JsonPropertyName("account_name")] public string? AccountName { get; init; } [JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; } [JsonPropertyName("joined_at")] public required long JoinedAt { get; init; } - [JsonPropertyName("label")] public string? Label { get; init; } } public sealed class AppendResult diff --git a/server/src/WebDrop.Core/Errors.cs b/server/src/WebDrop.Core/Errors.cs index b327ebc..8d23090 100644 --- a/server/src/WebDrop.Core/Errors.cs +++ b/server/src/WebDrop.Core/Errors.cs @@ -6,7 +6,7 @@ public enum ErrorCode Unauthorized, Forbidden, NotJoined, - SeatsFull, + MembersFull, ModeMismatch, NotFound, Conflict, @@ -47,7 +47,7 @@ public static class ErrorCodes ErrorCode.Unauthorized => "unauthorized", ErrorCode.Forbidden => "forbidden", ErrorCode.NotJoined => "not_joined", - ErrorCode.SeatsFull => "seats_full", + ErrorCode.MembersFull => "members_full", ErrorCode.ModeMismatch => "mode_mismatch", ErrorCode.NotFound => "not_found", ErrorCode.Conflict => "conflict", @@ -70,7 +70,7 @@ public static class ErrorCodes { ErrorCode.BadRequest => 400, ErrorCode.Unauthorized => 401, - ErrorCode.Forbidden or ErrorCode.NotJoined or ErrorCode.SeatsFull or ErrorCode.ModeMismatch => 403, + ErrorCode.Forbidden or ErrorCode.NotJoined or ErrorCode.MembersFull or ErrorCode.ModeMismatch => 403, ErrorCode.NotFound => 404, ErrorCode.Conflict => 409, ErrorCode.Sealed => 409, diff --git a/server/src/WebDrop.Core/Models.cs b/server/src/WebDrop.Core/Models.cs index 436b399..1581bae 100644 --- a/server/src/WebDrop.Core/Models.cs +++ b/server/src/WebDrop.Core/Models.cs @@ -82,7 +82,8 @@ public sealed record Item( public bool IsComplete => FinalIndex is { } f && FrameCount == f + 1; } -public sealed record Seat(byte[] EndpointHash, long JoinedAt, byte[]? Label, string? AccountName = null, string? AccountDeviceId = null); +/// 频道内的终端登记(账号设备或访客终端)。设备只是账号的属性,不是成员;不再有单独的设备名。 +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,7 +102,13 @@ public sealed record StoredFrame( public sealed record LogEntry(long Seq, LogKind Kind, Id16 ItemId, uint FrameIndex, byte Aux, long CreatedAt); /// 一次请求验证通过后的身份。 -public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null); +public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null) +{ + /// 受限下载的计数单位:账号按用户计(设备只是账号的属性),访客按终端计。 + public byte[] GrantKey => AccountName is null + ? EndpointHash + : System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes("webdrop/grant/v1/" + AccountName)); +} /// 请求携带的凭据,尚未验证。 public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash, string? AccountName = null, string? AccountDeviceId = null, diff --git a/server/src/WebDrop.Core/Settings.cs b/server/src/WebDrop.Core/Settings.cs index a18bb33..80c2e46 100644 --- a/server/src/WebDrop.Core/Settings.cs +++ b/server/src/WebDrop.Core/Settings.cs @@ -11,7 +11,7 @@ public sealed class ServerLimits public long MaxWorkspaceBytes { get; set; } = 1L << 40; public long MaxWorkspaceChannelBytes { get; set; } = 10L << 30; /// 最大账号成员数。属性名和 info 字段保持旧线格式兼容。 - public int MaxSeats { get; set; } = 64; + public int MaxMembers { get; set; } = 64; public int MaxVisitors { get; set; } = 10_000; public int MinTtlSeconds { get; set; } = 60; public int MaxTtlSeconds { get; set; } = 604_800; @@ -19,7 +19,6 @@ public sealed class ServerLimits public int InlineThresholdBytes { get; set; } = 64 * 1024; public int DownloadGraceSeconds { get; set; } = 300; public int TombstoneRetentionSeconds { get; set; } = 604_800; - public int MaxSeatLabelBytes { get; set; } = 256; } public static class ServerSettingChoices @@ -35,7 +34,7 @@ public sealed class ChannelSettings [JsonPropertyName("allow_guests")] public bool AllowGuests { get; set; } = true; [JsonPropertyName("name")] public string? Name { get; set; } /// 成员人数上限;线字段名保留为 seats 以兼容已有频道设置。 - [JsonPropertyName("seats")] public int Seats { get; set; } = 2; + [JsonPropertyName("max_members")] public int MaxMembers { get; set; } = 2; [JsonPropertyName("members_can_upload")] public bool MembersCanUpload { get; set; } = true; [JsonPropertyName("delete_policy")] public DeletePolicy DeletePolicy { get; set; } = DeletePolicy.Own; [JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; set; } = 86_400; @@ -47,7 +46,7 @@ public sealed class ChannelSettings { if (!Enum.IsDefined(JoinPolicy)) throw new WebDropException(ErrorCode.BadRequest); if (Name is { } name && (name.Length > 48 || name.Any(char.IsControl))) throw new WebDropException(ErrorCode.BadRequest); - if (Seats < 1 || Seats > limits.MaxSeats) throw new WebDropException(ErrorCode.BadRequest); + if (MaxMembers < 1 || MaxMembers > limits.MaxMembers) throw new WebDropException(ErrorCode.BadRequest); if (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds) throw new WebDropException(ErrorCode.BadRequest); if (MaxItems < 1 || MaxItems > limits.MaxItemsLimit) throw new WebDropException(ErrorCode.BadRequest); if (FileMaxDownloads < 0) throw new WebDropException(ErrorCode.BadRequest); @@ -60,7 +59,7 @@ public sealed class ChannelSettingsPatch [JsonPropertyName("allow_guests")] public bool? AllowGuests { get; init; } [JsonPropertyName("join_password")] public string? JoinPassword { get; init; } [JsonPropertyName("name")] public string? Name { get; init; } - [JsonPropertyName("seats")] public int? Seats { get; init; } + [JsonPropertyName("max_members")] public int? MaxMembers { get; init; } [JsonPropertyName("members_can_upload")] public bool? MembersCanUpload { get; init; } [JsonPropertyName("delete_policy")] public DeletePolicy? DeletePolicy { get; init; } [JsonPropertyName("ttl_seconds")] public int? TtlSeconds { get; init; } @@ -73,7 +72,7 @@ public sealed class ChannelSettingsPatch JoinPolicy = JoinPolicy ?? s.JoinPolicy, AllowGuests = AllowGuests ?? s.AllowGuests, Name = Name is null ? s.Name : string.IsNullOrWhiteSpace(Name) ? null : Name.Trim(), - Seats = Seats ?? s.Seats, + MaxMembers = MaxMembers ?? s.MaxMembers, MembersCanUpload = MembersCanUpload ?? s.MembersCanUpload, DeletePolicy = DeletePolicy ?? s.DeletePolicy, TtlSeconds = TtlSeconds ?? s.TtlSeconds, @@ -120,7 +119,7 @@ public static class CreationDefaults public static ChannelSettings Channel(ServerLimits limits) => new() { JoinPolicy = JoinPolicy.Invite, AllowGuests = false, - Seats = Math.Min(10, limits.MaxSeats), + MaxMembers = Math.Min(10, limits.MaxMembers), TtlSeconds = Math.Clamp(86_400, limits.MinTtlSeconds, limits.MaxTtlSeconds), MaxItems = Math.Min(1000, limits.MaxItemsLimit), }; diff --git a/server/src/WebDrop.Storage/SqliteChannelStore.cs b/server/src/WebDrop.Storage/SqliteChannelStore.cs index 22059c9..8a6c677 100644 --- a/server/src/WebDrop.Storage/SqliteChannelStore.cs +++ b/server/src/WebDrop.Storage/SqliteChannelStore.cs @@ -37,6 +37,8 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable 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;"); } + // 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. Exec(_writeConn, """ CREATE TABLE IF NOT EXISTS channel_key_packages ( @@ -456,7 +458,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable public bool HasInvitation(Id16 id, string username) => Scalar("SELECT 1 FROM channel_invitations WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username)) is not null; public void PutInvitation(ChannelInvitation i) => Run("INSERT INTO channel_invitations(channel_id,username,payload,created_at) VALUES(@id,@u,@p,@t) ON CONFLICT(channel_id,username) DO UPDATE SET payload=excluded.payload,created_at=excluded.created_at", ("@id", i.ChannelId.ToArray()), ("@u", i.Username), ("@p", i.Payload), ("@t", i.CreatedAt)); private static ChannelInvitation MapInvitation(SqliteDataReader r) => new(IdAt(r, 0), r.GetString(1), r.GetString(2), r.GetInt64(3)); - public IReadOnlyList ListInvitations(Id16 id) => Many("SELECT channel_id,username,payload,created_at FROM channel_invitations WHERE channel_id=@id", MapInvitation, ("@id", id.ToArray())); + public IReadOnlyList ListInvitations(Id16 id) => Many("SELECT channel_id,username,payload,created_at FROM channel_invitations i WHERE channel_id=@id AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id=i.channel_id AND m.username=i.username)", MapInvitation, ("@id", id.ToArray())); public IReadOnlyList ListUserInvitations(string username) => Many("SELECT i.channel_id,i.username,i.payload,i.created_at FROM channel_invitations i JOIN channels c ON c.id=i.channel_id WHERE i.username=@u AND c.expires_at>@now AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id=i.channel_id AND m.username=i.username)", MapInvitation, ("@u", username), ("@now", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())); public void DeleteInvitation(Id16 id, string username) => Run("DELETE FROM channel_invitations WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username)); public void PutKeyPackage(Id16 id, string username, string payload, long createdAt) => Run( @@ -513,7 +515,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable // ───────── 席位 ───────── - private static Seat MapSeat(SqliteDataReader r) => new(Blob(r, 0), r.GetInt64(1), BlobOrNull(r, 2), r.IsDBNull(3) ? null : r.GetString(3), r.IsDBNull(4) ? null : r.GetString(4)); + 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 endpointHash) => One( "SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c AND endpoint_hash=@e", @@ -526,17 +528,13 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable (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, label, joined_at,account_name,account_device_id) VALUES (@c,@e,@l,@j,@a,@d)", - ("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt), ("@a", seat.AccountName), ("@d", seat.AccountDeviceId)); + "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 endpointHash) => Run( "DELETE FROM seats WHERE channel_id=@c AND endpoint_hash=@e", ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())) > 0; - public void SetSeatLabel(Id16 channelId, ReadOnlySpan endpointHash, byte[] label) => Run( - "UPDATE seats SET label=@l WHERE channel_id=@c AND endpoint_hash=@e", - ("@l", label), ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())); - public IReadOnlyList 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())); diff --git a/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs b/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs index c7e20cb..4b3f1f3 100644 --- a/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs +++ b/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs @@ -18,7 +18,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture< [Fact] public async Task AccountKeyPackagesAreScopedToAcceptedMembersAndRemovedOnRevocation() { - var ch = await server.CreateAsync(settings: new { name = "Key package space", seats = 8 }); + var ch = await server.CreateAsync(settings: new { name = "Key package space", max_members = 8 }); var username = "keys_" + Guid.NewGuid().ToString("N")[..8]; var token = await Register(username); var member = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32)); using (var ownerPackage = server.Req(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/key-package", ch.Owner, JsonBody(new { payload = "opaque-owner-package" }))) @@ -31,7 +31,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture< using (var invite = await InviteAsync(ch, username, "opaque-member-package")) Assert.Equal(HttpStatusCode.NoContent, invite.StatusCode); using (var pending = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token)) Assert.Empty((await ReadJsonAsync(await server.Http.SendAsync(pending))).EnumerateArray()); - using (var accept = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, token)) + using (var accept = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, token)) Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accept)).StatusCode); using (var delivered = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token)) { @@ -90,7 +90,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture< byte[] ownerToken = RandomNumberGenerator.GetBytes(32); byte[] endpoint = RandomNumberGenerator.GetBytes(32); var owner = new Who("owner", ownerToken, endpoint); string spaceId = NewId(); using (var create = Request(HttpMethod.Post, "/api/v1/channels", owner, token, - new { id = spaceId, mode = "raw", settings = new { name = "Signed chat", seats = 2 }, owner_hash = B64(TokenHash.Compute(ownerToken)) })) + new { id = spaceId, mode = "raw", settings = new { name = "Signed chat", max_members = 2 }, owner_hash = B64(TokenHash.Compute(ownerToken)) })) Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(create)).StatusCode); string channelId; using (var structure = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/structure", owner, token)) @@ -163,26 +163,27 @@ 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", seats = 2, join_policy = "open", allow_guests = true }); + var ch = await server.CreateAsync(settings: new { name = "Device admission space", max_members = 2, join_policy = "open", allow_guests = true }); 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) }) { - using var joined = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who, token); + using var joined = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", who, token); using var response = await server.Http.SendAsync(joined); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(2, (await ReadJsonAsync(response)).GetProperty("seats_used").GetInt32()); + Assert.Equal(2, (await ReadJsonAsync(response)).GetProperty("members_used").GetInt32()); } using (var info = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner)) - Assert.Equal(2, (await ReadJsonAsync(await server.Http.SendAsync(info))).GetProperty("seats_used").GetInt32()); + Assert.Equal(2, (await ReadJsonAsync(await server.Http.SendAsync(info))).GetProperty("members_used").GetInt32()); var unrelated = await Register("full_" + Guid.NewGuid().ToString("N")[..8]); var third = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32)); - using (var full = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", third, unrelated)) - await AssertErrorAsync(await server.Http.SendAsync(full), 403, "seats_full"); + using (var full = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", third, unrelated)) + await AssertErrorAsync(await server.Http.SendAsync(full), 403, "members_full"); Assert.Equal(2, accounts.Devices(username, firstId).Count); Assert.Single(accounts.Devices(username, firstId), d => d.Current); - using (var impersonate = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", second, firstToken)) - await AssertErrorAsync(await server.Http.SendAsync(impersonate), 401, "account_required"); + // 账号成员只凭账号会话鉴权,不再绑定到某个终端:同一账号的任一设备会话都能读取 + using (var anyDevice = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", second, firstToken)) + Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(anyDevice)).StatusCode); using (var revoke = Request(HttpMethod.Delete, $"/api/v1/accounts/devices/{secondId}", first, firstToken)) Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode); Assert.True(accounts.HasSession(firstToken, out _)); Assert.False(accounts.HasSession(secondToken, out _)); @@ -195,7 +196,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture< [Fact] public void PartialSettingsPreserveAdmissionDefaults() { - var request = JsonSerializer.Deserialize("{\"settings\":{\"seats\":8}}", ApiJson.Default.CreateChannelRequest)!; + 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()); @@ -212,6 +213,32 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture< await AssertErrorAsync(response, 400, "bad_request"); } } + [Fact] + public async Task AccountMembersNeedNoTerminalKeyAndDoNotOccupyGuestRegistrations() + { + var ch = await server.CreateAsync(settings: new { name = "Account only", max_members = 3, join_policy = "invite", allow_guests = false }); + 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); + var who = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32)); + using (var join = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", who, account)) + Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(join)).StatusCode); + + // 登录账号不带终端密钥也能鉴权 + using (var log = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", who, account)) + { + 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); + Assert.True(mine.GetProperty("devices").GetInt32() >= mine.GetProperty("online_devices").GetInt32()); + } + private async Task Register(string username) { var accounts = server.App.Services.GetRequiredService(); @@ -290,32 +317,33 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture< [InlineData(ChannelMode.Raw)] public async Task PasswordInvitationsAndRevocationUseTheSameChannelRules(ChannelMode mode) { - var ch = await server.CreateAsync(mode, new { name = "Password space", seats = 8 }); + var ch = await server.CreateAsync(mode, new { name = "Password space", max_members = 8 }); using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_password = "channel-secret" }))) Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode); var guest = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32)); - using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest)) + using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest)) await AssertErrorAsync(await server.Http.SendAsync(missing), 403, "channel_password_required"); - using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest, body: new { password = "wrong" })) + using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, body: new { password = "wrong" })) await AssertErrorAsync(await server.Http.SendAsync(wrong), 403, "channel_password_invalid"); - using (var correct = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest, body: new { password = "channel-secret" })) + using (var correct = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest, body: new { password = "channel-secret" })) Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(correct)).StatusCode); using (var closed = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { allow_guests = false }))) Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(closed)).StatusCode); - using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }, body: new { password = "channel-secret" })) + using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", guest with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }, body: new { password = "channel-secret" })) await AssertErrorAsync(await server.Http.SendAsync(stranger), 401, "account_required"); using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_policy = "invite", allow_guests = false }))) Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode); var username = "invited_" + Guid.NewGuid().ToString("N")[..8]; var account = await Register(username); var member = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32)); - using (var uninvited = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account)) + using (var uninvited = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, account)) await AssertErrorAsync(await server.Http.SendAsync(uninvited), 403, "invitation_required"); 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}/seats", member, account)) + 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 impersonated = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member)) - await AssertErrorAsync(await server.Http.SendAsync(impersonated), 401, "account_required"); + // 只有链接令牌与终端密钥、没有账号会话的请求没有成员身份,也没有访客登记 + using (var withoutAccount = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member)) + await AssertErrorAsync(await server.Http.SendAsync(withoutAccount), 403, "not_joined"); 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)) @@ -335,7 +363,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture< } using (var revoke = server.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/{username}", ch.Owner)) Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode); - using (var rejoin = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account)) + using (var rejoin = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", member, account)) await AssertErrorAsync(await server.Http.SendAsync(rejoin), 403, "forbidden"); } diff --git a/server/tests/WebDrop.Api.Tests/RestTests.cs b/server/tests/WebDrop.Api.Tests/RestTests.cs index 74b4b17..63a4ea1 100644 --- a/server/tests/WebDrop.Api.Tests/RestTests.cs +++ b/server/tests/WebDrop.Api.Tests/RestTests.cs @@ -44,8 +44,8 @@ public class RestTests(ServerFixture s) : IClassFixture var info = await ReadJsonAsync(get); Assert.Equal(ch.Id, info.GetProperty("id").GetString()); Assert.Equal("enc", info.GetProperty("mode").GetString()); - Assert.Equal(1, info.GetProperty("seats_used").GetInt32()); - Assert.Equal(2, info.GetProperty("settings").GetProperty("seats").GetInt32()); + Assert.Equal(1, info.GetProperty("members_used").GetInt32()); + Assert.Equal(2, info.GetProperty("settings").GetProperty("max_members").GetInt32()); Assert.Equal("own", info.GetProperty("settings").GetProperty("delete_policy").GetString()); Assert.Equal(1, info.GetProperty("oldest_seq").GetInt64()); @@ -124,13 +124,13 @@ public class RestTests(ServerFixture s) : IClassFixture return await s.Http.SendAsync(request); } - using (var patch = await OwnerRequest(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", JsonBody(new { seats = 3 }))) + 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 seats = await OwnerRequest(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats")) - Assert.Equal(HttpStatusCode.OK, seats.StatusCode); - using (var absentSeat = await OwnerRequest(HttpMethod.Delete, - $"/api/v1/channels/{ch.Id}/seats/{B64(RandomNumberGenerator.GetBytes(32))}")) - await AssertErrorAsync(absentSeat, 404, "not_found"); + using (var 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,33 +232,34 @@ public class RestTests(ServerFixture s) : IClassFixture } [Fact] - public async Task SeatsJoinListAndRelease() + public async Task JoinListGuestsAndRelease() { var ch = await s.CreateAsync(); using var joined = await s.JoinAsync(ch, ch.Reader); var j = await ReadJsonAsync(joined); - Assert.Equal(2, j.GetProperty("seats_used").GetInt32()); - Assert.Equal(2, j.GetProperty("seats_max").GetInt32()); - string memberHash = j.GetProperty("endpoint_hash").GetString()!; + 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)); - await AssertErrorAsync(await s.JoinAsync(ch, third), 403, "seats_full"); + 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}/seats", ch.Reader)), 403, "forbidden"); - using var list = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats", ch.Owner)); - Assert.Equal(2, (await ReadJsonAsync(list)).GetArrayLength()); + // 访客清单只有所有者能看;账号成员不在其中 + 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 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.NoContent, label.StatusCode); - using var list2 = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats", ch.Owner)); - Assert.Contains((await ReadJsonAsync(list2)).EnumerateArray(), e => e.TryGetProperty("label", out var l) && l.GetString() == B64([9, 8, 7])); + Assert.Equal(HttpStatusCode.NotFound, label.StatusCode); - // 所有者释放后,成员失去访问 - using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/seats/{memberHash}", ch.Owner)); + // 所有者移除访客后,成员失去访问 + 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); await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "not_joined"); } @@ -269,17 +270,17 @@ public class RestTests(ServerFixture s) : IClassFixture var ch = await s.CreateAsync(); await s.JoinAsync(ch, ch.Member); - var patch = () => JsonBody(new { seats = 4, members_can_upload = false }); + var patch = () => JsonBody(new { max_members = 4, members_can_upload = false }); await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Member, patch())), 403, "forbidden"); using var ok = await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, patch())); var settings = (await ReadJsonAsync(ok)).GetProperty("settings"); - Assert.Equal(4, settings.GetProperty("seats").GetInt32()); + Assert.Equal(4, settings.GetProperty("max_members").GetInt32()); Assert.False(settings.GetProperty("members_can_upload").GetBoolean()); await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "forbidden"); await AssertErrorAsync( - await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { seats = 0 }))), + await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { max_members = 0 }))), 400, "bad_request"); } diff --git a/server/tests/WebDrop.Api.Tests/ServerFixture.cs b/server/tests/WebDrop.Api.Tests/ServerFixture.cs index 0ff4fdc..9155234 100644 --- a/server/tests/WebDrop.Api.Tests/ServerFixture.cs +++ b/server/tests/WebDrop.Api.Tests/ServerFixture.cs @@ -118,7 +118,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", seats = 2, join_policy = "open", allow_guests = true }; + body["settings"] = settings ?? new { name = "Test space", max_members = 2, join_policy = "open", allow_guests = true }; 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); @@ -136,7 +136,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable } public async Task JoinAsync(Chan ch, Who who) => - await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who)); + await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", who)); public async Task AppendAsync( Chan ch, diff --git a/server/tests/WebDrop.Api.Tests/StreamingTests.cs b/server/tests/WebDrop.Api.Tests/StreamingTests.cs index fc6b865..ac4a725 100644 --- a/server/tests/WebDrop.Api.Tests/StreamingTests.cs +++ b/server/tests/WebDrop.Api.Tests/StreamingTests.cs @@ -116,13 +116,13 @@ public class StreamingTests(ServerFixture s) : IClassFixture public async Task WebSocketNotifiesSeatChangesAndClosesWhenRevoked() { var ch = await s.CreateAsync(); - var joined = await s.JoinAsync(ch, ch.Member); - string memberHash = (await ReadJsonAsync(joined)).GetProperty("endpoint_hash").GetString()!; + 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}/seats/{memberHash}", ch.Owner)); + 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); bool closed = false; @@ -137,7 +137,7 @@ public class StreamingTests(ServerFixture s) : IClassFixture } else if (type == WebSocketMessageType.Text) { - Assert.Equal("seats", JsonDocument.Parse(data).RootElement.GetProperty("t").GetString()); + Assert.Equal("members", JsonDocument.Parse(data).RootElement.GetProperty("t").GetString()); } } Assert.True(closed); @@ -240,4 +240,39 @@ public class StreamingTests(ServerFixture s) : IClassFixture var bad = new Who("owner", new byte[32], ch.OwnerSecret); await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/events", bad)), 401, "unauthorized"); } + [Fact] + public async Task MembersReportAccountPresenceFromActiveSubscriptions() + { + var ch = await s.CreateAsync(); + async Task<(bool Online, int Devices, int OnlineDevices)> Owner() + { + using var response = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/members", ch.Owner)); + response.EnsureSuccessStatusCode(); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var owner = doc.RootElement.EnumerateArray().First(m => m.GetProperty("role").GetString() == "owner"); + return (owner.GetProperty("online").GetBoolean(), owner.GetProperty("devices").GetInt32(), owner.GetProperty("online_devices").GetInt32()); + } + async Task<(bool Online, int Devices, int OnlineDevices)> Eventually(Func<(bool Online, int Devices, int OnlineDevices), bool> done) + { + for (int i = 0; i < 50; i++) + { + var state = await Owner(); + if (done(state)) return state; + await Task.Delay(100); + } + return await Owner(); + } + + Assert.False((await Owner()).Online); + using var ws = await ConnectWs(ch, ch.Owner); + var online = await Eventually(state => state.Online); + Assert.True(online.Online); + Assert.Equal(1, online.OnlineDevices); + Assert.True(online.Devices >= 1); + + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); + var offline = await Eventually(state => !state.Online); + Assert.False(offline.Online); + Assert.Equal(0, offline.OnlineDevices); + } } diff --git a/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs b/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs index d33fe20..879b485 100644 --- a/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs +++ b/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs @@ -47,18 +47,18 @@ public sealed class WorkspaceApiTests(ServerFixture server) : IClassFixture Svc.Join(ch.Id, ch.Reader(TestEnv.Endpoint()))); - Assert.Equal(1, _env.Events.Seats); // 只有真正新增时才通知 + AssertCode(ErrorCode.MembersFull, () => Svc.Join(ch.Id, ch.Reader(TestEnv.Endpoint()))); + Assert.Equal(1, _env.Events.Members); // 只有真正新增时才通知 } [Fact] public void AccountMembersUseOneLimitAcrossUnlimitedDevices() { - var ch = _env.Create(_ownerEp, settings: new ChannelSettings { Seats = 2 }); + var ch = _env.Create(_ownerEp, settings: new ChannelSettings { MaxMembers = 2 }); var memberOne = TestEnv.Endpoint(); var memberTwo = TestEnv.Endpoint(); var ownerTwo = TestEnv.Endpoint(); Credentials alice(byte[] endpoint, string device) => new(Role.Read, ch.ReadToken, endpoint, "alice", device); - Assert.Equal(2, Svc.Join(ch.Id, alice(memberOne, "alice-1")).SeatsUsed); - Assert.Equal(2, Svc.Join(ch.Id, alice(memberTwo, "alice-2")).SeatsUsed); - Assert.Equal(2, Svc.Join(ch.Id, ch.Owner(ownerTwo)).SeatsUsed); - Assert.Equal(4, Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)).Count); // 设备仍单独登记,但不计入成员人数 + 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.SeatsFull, () => Svc.Join(ch.Id, bob)); + AssertCode(ErrorCode.MembersFull, () => Svc.Join(ch.Id, bob)); - Svc.ReleaseSeat(ch.Id, ch.Owner(_ownerEp), memberOne); - Assert.Equal(2, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).SeatsUsed); // 移除一台设备不会释放账号成员名额 - AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() })); + 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)).SeatsUsed); - Assert.Equal(2, Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }).SeatsUsed); + Assert.Equal(1, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).MembersUsed); + Assert.Equal(2, Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }).MembersUsed); } [Fact] @@ -209,29 +207,25 @@ public class ChannelServiceTests : IDisposable public async Task ReleasedSeatLosesAccess() { var ch = _env.Create(_ownerEp); - var joined = Svc.Join(ch.Id, ch.Member(_memberEp)); + Svc.Join(ch.Id, ch.Member(_memberEp)); await AppendSmall(ch, ch.Member(_memberEp)); - Svc.ReleaseSeat(ch.Id, ch.Owner(_ownerEp), TokenHash.Compute(TestEnv.Random(1)).Length == 0 ? [] : Codec_Decode(joined.EndpointHash)); + Svc.ReleaseGuest(ch.Id, ch.Owner(_ownerEp), _memberEp); await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp))); } - private static byte[] Codec_Decode(string b64) => - Codec.TryDecodeExact(b64, 32, out var bytes) ? bytes : throw new InvalidOperationException(); - [Fact] - public void OnlyTheOwnerManagesSeatsAndSettings() + public void OnlyTheOwnerManagesGuestsAndSettings() { var ch = _env.Create(_ownerEp); Svc.Join(ch.Id, ch.Member(_memberEp)); - AssertCode(ErrorCode.Forbidden, () => Svc.ListSeats(ch.Id, ch.Member(_memberEp))); - AssertCode(ErrorCode.Forbidden, () => Svc.UpdateSettings(ch.Id, ch.Member(_memberEp), new ChannelSettingsPatch { Seats = 5 })); + 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.ReleaseSeat(ch.Id, ch.Member(_memberEp), _ownerEp)); + AssertCode(ErrorCode.Forbidden, () => Svc.ReleaseGuest(ch.Id, ch.Member(_memberEp), _ownerEp)); - var seats = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)); - Assert.Equal(2, seats.Count); + Assert.Single(Svc.ListGuests(ch.Id, ch.Owner(_ownerEp))); // 访客只有成员那一台 } [Fact] @@ -243,25 +237,13 @@ 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 { Seats = 3 }).Settings.Seats); - Assert.Equal(2, Svc.ListSeats(ch.Id, unseatedOwner).Count); - AssertCode(ErrorCode.NotFound, () => Svc.ReleaseSeat(ch.Id, unseatedOwner, TestEnv.Endpoint())); + 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))); } - [Fact] - public void SeatLabelIsStoredAndBounded() - { - var ch = _env.Create(_ownerEp); - Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), [1, 2, 3]); - var mine = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)).Single(); - Assert.Equal(Codec.Encode([1, 2, 3]), mine.Label); - - AssertCode(ErrorCode.TooLarge, () => Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), new byte[257])); - AssertCode(ErrorCode.BadRequest, () => Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), [])); - } - [Fact] public void UpdateSettingsAppliesPatchAndRecomputesExpiry() { @@ -269,15 +251,15 @@ public class ChannelServiceTests : IDisposable var before = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)); _env.Clock.Advance(TimeSpan.FromMinutes(1)); - var after = Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { Seats = 3, TtlSeconds = 3600 }); + var after = Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { MaxMembers = 3, TtlSeconds = 3600 }); - Assert.Equal(3, after.Settings.Seats); + Assert.Equal(3, after.Settings.MaxMembers); Assert.Equal(3600, after.Settings.TtlSeconds); Assert.Equal(_env.Clock.NowMs + 3600_000L, after.ExpiresAt); Assert.True(after.ExpiresAt < before.ExpiresAt); Assert.Equal(1, _env.Events.Settings); - AssertCode(ErrorCode.BadRequest, () => Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { Seats = 0 })); + AssertCode(ErrorCode.BadRequest, () => Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { MaxMembers = 0 })); } // ───── 追加:幂等、形状、权限 ───── @@ -524,7 +506,7 @@ public class ChannelServiceTests : IDisposable public async Task ResumeNeedsACountedDownloadFromTheSameEndpoint() { var otherEndpoint = TestEnv.Endpoint(); - var ch = _env.Create(_ownerEp, settings: new ChannelSettings { Seats = 3, FileMaxDownloads = 1 }); + var ch = _env.Create(_ownerEp, settings: new ChannelSettings { MaxMembers = 3, FileMaxDownloads = 1 }); Svc.Join(ch.Id, ch.Reader(_memberEp)); Svc.Join(ch.Id, ch.Reader(otherEndpoint)); var item = await UploadGated(ch, ch.Owner(_ownerEp), 2); diff --git a/server/tests/WebDrop.Core.Tests/PrimitivesTests.cs b/server/tests/WebDrop.Core.Tests/PrimitivesTests.cs index defd22e..00231be 100644 --- a/server/tests/WebDrop.Core.Tests/PrimitivesTests.cs +++ b/server/tests/WebDrop.Core.Tests/PrimitivesTests.cs @@ -155,9 +155,9 @@ public class PolicyTests [Fact] public void SettingsPatchOnlyChangesProvidedFields() { - var before = new ChannelSettings { Seats = 2, TtlSeconds = 100 }; - var after = new ChannelSettingsPatch { Seats = 5 }.ApplyTo(before); - Assert.Equal(5, after.Seats); + var before = new ChannelSettings { MaxMembers = 2, TtlSeconds = 100 }; + var after = new ChannelSettingsPatch { MaxMembers = 5 }.ApplyTo(before); + Assert.Equal(5, after.MaxMembers); Assert.Equal(100, after.TtlSeconds); } @@ -169,7 +169,7 @@ public class PolicyTests [InlineData(2, 86400, 0)] public void SettingsValidationRejectsOutOfRange(int seats, int ttl, int maxItems) { - var s = new ChannelSettings { Seats = seats, TtlSeconds = ttl, MaxItems = maxItems }; + var s = new ChannelSettings { MaxMembers = seats, TtlSeconds = ttl, MaxItems = maxItems }; var ex = Assert.Throws(() => s.Validate(new ServerLimits())); Assert.Equal(ErrorCode.BadRequest, ex.Code); } diff --git a/server/tests/WebDrop.Core.Tests/TestEnv.cs b/server/tests/WebDrop.Core.Tests/TestEnv.cs index 5edf905..2f40973 100644 --- a/server/tests/WebDrop.Core.Tests/TestEnv.cs +++ b/server/tests/WebDrop.Core.Tests/TestEnv.cs @@ -14,10 +14,10 @@ public sealed class FakeClock : IClock public sealed class RecordingEvents : IChannelEvents { public List<(Id16 Channel, long Seq)> Advances { get; } = []; - public int Settings, Seats, Deleted; + public int Settings, Members, Deleted; public void Advanced(Id16 channelId, long seq) { lock (Advances) Advances.Add((channelId, seq)); } public void SettingsChanged(Id16 channelId) => Interlocked.Increment(ref Settings); - public void SeatsChanged(Id16 channelId) => Interlocked.Increment(ref Seats); + public void MembersChanged(Id16 channelId) => Interlocked.Increment(ref Members); public void ChannelDeleted(Id16 channelId) => Interlocked.Increment(ref Deleted); } diff --git a/web/src/core-bridge/generated/api.ts b/web/src/core-bridge/generated/api.ts index 177c74b..8b80900 100644 --- a/web/src/core-bridge/generated/api.ts +++ b/web/src/core-bridge/generated/api.ts @@ -1,5 +1,5 @@ // Generated from backend DTOs; do not edit. -// backend-contract-sha256: ba787115471d5d33fb0c33c95af014b5cd0d535455bd4bea037cfdcc4f048828 +// backend-contract-sha256: 54ea3f00fa672e0241efbb9fc8e7ad9e8fc68415ec124e08f70657c2d57d9ff9 export interface AccountChannelBody { channel_id: string; @@ -79,10 +79,10 @@ export interface ChannelInfo { expires_at: number; id: string; last_seq: number; + members_used: number; mode: ChannelMode; oldest_seq: number; password_required: boolean; - seats_used: number; settings: ChannelSettings; } export type ChannelMode = 'enc' | 'raw'; @@ -93,9 +93,9 @@ export interface ChannelSettings { file_max_downloads: number; join_policy: JoinPolicy; max_items: number; + max_members: number; members_can_upload: boolean; name?: string | null; - seats: number; ttl_seconds: number; ui_restrict_copy_download: boolean; } @@ -106,9 +106,9 @@ export interface ChannelSettingsPatch { join_password?: string | null; join_policy?: JoinPolicy | null; max_items?: number | null; + max_members?: number | null; members_can_upload?: boolean | null; name?: string | null; - seats?: number | null; ttl_seconds?: number | null; ui_restrict_copy_download?: boolean | null; } @@ -171,6 +171,10 @@ 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; @@ -198,11 +202,19 @@ export type JoinPolicy = 'open' | 'invite'; export interface JoinRequest { password?: string | null; } +export interface JoinResult { + joined_at: number; + members_max: number; + members_used: number; +} export interface KeyPackageRequest { payload: string; } export interface MemberBody { + devices: number; joined_at: number; + online: boolean; + online_devices: number; role: string; username: string; } @@ -260,18 +272,6 @@ export interface PublicKeyBody { public_key: string; signing_public_key?: string | null; } -export interface SeatInfo { - account_name?: string | null; - endpoint_hash: string; - joined_at: number; - label?: string | null; -} -export interface SeatJoined { - endpoint_hash: string; - joined_at: number; - seats_max: number; - seats_used: number; -} export interface ServerInfoBody { captcha: CaptchaBody; choices: SettingChoicesBody; @@ -295,8 +295,8 @@ export interface LimitsBody { max_channel_bytes: number; max_frames_per_item: number; max_items: number; + max_members: number; max_payload_bytes: number; - max_seats: number; max_ttl_seconds: number; max_visitors: number; max_workspace_bytes: number; @@ -305,8 +305,8 @@ export interface LimitsBody { } export interface SettingChoicesBody { file_max_downloads: number[]; + max_members: number[]; max_visitors: number[]; - seats: number[]; ttl_seconds: number[]; } export interface ShareInfo {