Compare commits
3
Commits
0fd2a269d9
...
4b8d0b5a5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b8d0b5a5f | ||
|
|
5fd4ddd411 | ||
|
|
d946fc5e8a |
No files matched your search
@@ -35,7 +35,7 @@
|
||||
|
||||
- **频道**:唯一的顶层概念,一条只追加的帧流,有自己的 id、密钥、设置和账号成员人数上限。
|
||||
- **条目**:频道里的一项内容(文本、JSON、文件),由若干帧组成,可复制、下载、删除。
|
||||
- **终端**:加入频道的设备或会话,每个设备有独立终端席位;频道容量按账号成员人数计算,同一账号的多设备只算一人。
|
||||
- **成员**:频道里的人是用户(账号);设备只是账号的属性,同一账号的多设备只算一人,请求凭账号会话鉴权。未登录的**访客**以终端密钥 `S` 登记,每个访客终端各算一人。
|
||||
- **所有者 / 成员**:所有者持有管理密钥,成员通过频道链接加入。
|
||||
- **模式**:`enc`(默认,浏览器端 AES-256-GCM)和 `raw`(负载原样,服务器可见,用于公开或用户自行加密的内容)。
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "6.0.0"
|
||||
"version": "7.0.0"
|
||||
}
|
||||
+71
-83
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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,保留已选择保存的填充资料,明确清除时全部移除。登录态与本机资源数据库在同一启动屏障后恢复,避免先加载访客资源再切换账号。
|
||||
|
||||
|
||||
+26
-27
@@ -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 <base64url(令牌)>` |
|
||||
| `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": "<channel_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": "<sha256(T_read)>",
|
||||
"write_hash": "<sha256(T_write)>",
|
||||
@@ -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.<base64url(token)>` 子协议传递,并在推送循环中重新检查有效性。
|
||||
频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号会话(或访客终端)、频道成员关系与写入权限,不能靠客户端隐藏按钮绕过。移除账号成员才会释放成员名额;撤销账号的某台登录设备只使该设备的会话失效,不影响成员身份。WebSocket 账号 session 经 `wd.account.<base64url(token)>` 子协议传递,并在推送循环中重新检查有效性。
|
||||
|
||||
### `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=<seq>`
|
||||
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=<index>`
|
||||
拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载;`from>0` 只有同一终端此前对同一条目成功启动过 `from=0` 才能续传,否则返回 `410 gone`。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
|
||||
拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载;`from>0` 只有同一下载方(登录账号按用户,访客按终端)此前对同一条目成功启动过 `from=0` 才能续传,否则返回 `410 gone`。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
|
||||
|
||||
### `DELETE /api/v1/channels/{id}/items/{item_id}`
|
||||
按 `delete_policy` 授权后删除条目,并在日志中追加 tombstone。已登记签名设备还必须提交对应 `chat.delete`/`file.delete` 事件头;服务端验证其绑定的设备证书、权限和原对象帧 manifest 后保存删除签名,浏览器验签后才应用 tombstone。
|
||||
|
||||
### 设备终端席位
|
||||
- `GET /api/v1/channels/{id}/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 合并,不写入服务端明文账号表。昵称不改变固定用户名或参与密钥派生。
|
||||
@@ -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 级文件:并行上传和断点续传 | 中途断网后续传成功,内存占用平稳 |
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
+3
-3
@@ -101,7 +101,7 @@ type ItemState =
|
||||
| 降级防御 | 链接含 `k` 就只接受 enc 且认证通过的帧 |
|
||||
| 不把密钥、令牌、明文写入 `console`、错误上报、URL、`localStorage` | 「我的频道」只存 IndexedDB |
|
||||
| 便捷分享草稿预览 | 原始文件只缓存在创建者本机 IndexedDB,不会随文件帧上传;移除条目或封存时清理,用于刷新后继续预览草稿 |
|
||||
| 账号资源保险库 | Worker 派生登录凭据与 AES-GCM 密钥;同步资源访问凭据、邀请私钥和移除墓碑;不含个人资料、草稿内容、终端 S、设备名或 Blob。按账号/访客分库,会话通过本机 IndexedDB 恢复 |
|
||||
| 账号资源保险库 | Worker 派生登录凭据与 AES-GCM 密钥;同步资源访问凭据、邀请私钥和移除墓碑;不含个人资料、草稿内容、访客终端 S 或 Blob。按账号/访客分库,会话通过本机 IndexedDB 恢复 |
|
||||
| 频道与文件夹 | 后端持久化结构和资源归属,Worker 读取结构快照;加密文件元数据中的原始位置用于内容校验,UI 使用后端当前归属 |
|
||||
| 条目预览在沙箱 iframe 渲染;raw 内容只当纯文本显示或下载 | 无内联渲染,不使用 `dangerouslySetInnerHTML` |
|
||||
| 严格 CSP,所有脚本样式带 SRI,不使用内联脚本 | 与 architecture §5 一致 |
|
||||
@@ -171,7 +171,7 @@ type ItemState =
|
||||
|
||||
## 12. 实际边界与会话
|
||||
|
||||
core-bridge/instance.ts 使用真实 Worker 实现。账号、设备和个人资料由 worker/account.ts 与 accountProfile.ts 编排;账号密钥包通过 accountChannels.ts 与 channelGrant.ts 领取和核对;空间成员/设置/设备席位通过 channelResources.ts 调用 API;频道、分组和目录由 channelThreads.ts 调用后端。workspaceRecords.ts 只读取加密文件元数据,不处理账户或结构操作。
|
||||
core-bridge/instance.ts 使用真实 Worker 实现。账号、设备和个人资料由 worker/account.ts 与 accountProfile.ts 编排;账号密钥包通过 accountChannels.ts 与 channelGrant.ts 领取和核对;空间成员/设置/加入通过 channelResources.ts 调用 API;频道、分组和目录由 channelThreads.ts 调用后端。workspaceRecords.ts 只读取加密文件元数据,不处理账户或结构操作。
|
||||
|
||||
@webdrop/core 仅实现纯密码学与帧协议,不访问数据库或网络。sessions.ts 管本机已解锁会话。transfer.ts 管持久化密文发送队列,队列按资源/创建顺序索引,确保文件密文帧先于目录记录上传。downloadStorage.ts 优先使用 OPFS 逐块写入临时文件,认证结束后交付;不支持时最多 64 MiB 的 Blob 回退。
|
||||
|
||||
@@ -197,7 +197,7 @@ ContextMenu 是无业务 UI,负责 portal、视口限制、键盘焦点和关
|
||||
|
||||
ItemBase.author 由服务端资源作者和账号资料投影得到,晚到的解密内容不能覆盖服务器提供的作者与位置。客户端提供的作者声明不能覆盖服务端资料。这是服务端身份归属,不是端对端逐账号签名。
|
||||
|
||||
ChatPane 订阅当前频道的条目 ID,ChatMessage 单独订阅条目状态和上传进度;文件目录变成文件消息,不重复创建独立文本通知。预览/下载继续经已有 store action,移动文件不会重新上传。GATED 内容只有明确操作后才读取。
|
||||
ChatPane 订阅当前频道的条目 ID(乐观发送:store 的 sendText/sendFiles 先写入 `local:` 占位项,worker 经 `localId` 带回后由 reduce 替换,失败可 `retrySend`),ChatMessage 单独订阅条目状态和上传进度;文件目录变成文件消息,不重复创建独立文本通知。预览/下载继续经已有 store action,移动文件不会重新上传。GATED 内容只有明确操作后才读取。
|
||||
|
||||
## 17. 本机凭据保存
|
||||
|
||||
|
||||
+45
-77
@@ -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'
|
||||
+13
-14
@@ -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。
|
||||
|
||||
|
||||
@@ -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 存储解密后临时文件,正常退出账号时清理;浏览器崩溃可能留下本机缓存,它不属于服务端密文。
|
||||
+18
-6
@@ -148,7 +148,7 @@ WebDrop 是**暂存区**,频道工作区:聊天按 thread 展示,文件按 thr
|
||||
|
||||
### 6.4.1 便捷分享
|
||||
|
||||
Share 是独立的只读发布资源,不创建频道成员、threads 或席位。创建者在草稿中添加内容和设置,明确确认后封存;服务端清除 owner_hash,前端丢弃管理密钥,此后只有读取入口。访客打开分享链接直接读取加密清单,下载受限文件时计次数,不经过频道加入流程。详见 protocol.md §15。
|
||||
Share 是独立的只读发布资源,不创建频道成员或 threads。创建者在草稿中添加内容和设置,明确确认后封存;服务端清除 owner_hash,前端丢弃管理密钥,此后只有读取入口。访客打开分享链接直接读取加密清单,下载受限文件时计次数,不经过频道加入流程。详见 protocol.md §15。
|
||||
|
||||
### 6.5 频道 thread、文件管理与聊天
|
||||
|
||||
@@ -258,7 +258,7 @@ Share 是独立的只读发布资源,不创建频道成员、threads 或席位
|
||||
|
||||
| 码 | 页面 |
|
||||
|---|---|
|
||||
| `seats_full` | 加入预览页:「成员人数已满」,告知联系所有者 |
|
||||
| `members_full` | 加入预览页:「成员人数已满」,告知联系所有者 |
|
||||
| `not_joined`(被释放) | 频道页整体替换为「你已被移出此频道」 |
|
||||
| `not_found` / `gone` | 「频道不存在或已过期」 |
|
||||
| `rate_limited` | Toast,带重试倒计时 |
|
||||
@@ -331,12 +331,20 @@ Share 是独立的只读发布资源,不创建频道成员、threads 或席位
|
||||
|
||||
## 13. 空间、频道与频道分组
|
||||
|
||||
界面层级统一为「空间 → 频道分组(可选)→ 频道 → 聊天与文件夹」。原顶层频道对用户称为空间,原 thread 对用户称为频道。新建空间自动创建“常规”频道。空间负责成员、邀请、成员人数上限和加密边界;账号内设备不重复计入人数,设备席位只用于单设备认证与撤销。频道分组只组织导航,不改变权限或加密密钥。
|
||||
界面层级统一为「空间 → 频道分组(可选)→ 频道 → 聊天与文件夹」。原顶层频道对用户称为空间,原 thread 对用户称为频道。新建空间自动创建“常规”频道。空间负责成员、邀请、成员人数上限和加密边界;账号内设备不重复计入人数,设备只是账号的属性,只在账号设置里管理。频道分组只组织导航,不改变权限或加密密钥。
|
||||
|
||||
左侧上段显示账号与空间列表,下段按分组展示频道。支持新建/重命名分组、折叠分组、在分组内新建频道、将已有频道移入其他分组或移回未分组。旧 thread 自动作为未分组频道展示。中间显示当前频道聊天,右侧显示当前频道文件夹和文件。
|
||||
桌面最左是 64px 的空间窄栏(每个空间一个首字方块,当前空间反色;下方是新建空间与加入空间),其右 240px 的频道列:顶部是空间名与加密状态(点击打开空间设置),下面按分组展示频道,分组标题小号灰字,悬停才显示 + 与 ⋯,频道行 32px、选中为整行淡底;便签并入频道列底部。账号卡片贴在左下角横跨两栏。手机上空间栏变成横向一排方块,空间名栏隐藏。支持新建/重命名分组、折叠分组、在分组内新建频道、将已有频道移入其他分组或移回未分组。旧 thread 自动作为未分组频道展示。中间显示当前频道聊天,右侧显示当前频道文件夹和文件。
|
||||
|
||||
空间设置集中管理成员、邀请和空间权限;频道管理提供名称与所属分组。分组名及归属由后端持久化并通过通知刷新,刷新与其他设备保持一致。协议/API 内部保留 channels/thread_id 等标识,避免因界面术语变更重建已有数据。
|
||||
|
||||
### 13.1 成员栏、邀请与通知
|
||||
|
||||
右侧栏顶部是「文件 / 成员 n」页签,默认文件;顶栏的人数按钮直接打开成员页,空间主还有一个 + 直接打开邀请弹窗。手机上「聊天 / 文件」切换里多一项「成员」。
|
||||
|
||||
成员页以用户为单位,按 Discord 的分组排列:在线 — n、离线 — n、待接受 — n(仅空间主可见,有「撤销」)。每行头像、昵称(有昵称时下方小字显示用户名),空间主带「空间主」标签;一个用户有多台设备时小字显示「在线设备/设备数 台设备在线」。**设备不再单独列出,只是用户的属性。**在线状态与设备数由后端按账号统计(`GET /channels/{id}/members` 的 `online`、`devices`、`online_devices`,在线 = 该账号在本频道有活动订阅),前端在成员页可见期间每 8 秒读取一次,不改动订阅流。空间主在页头有醒目的「+ 邀请成员」,成员行悬停出现「移除」(需确认)。邀请与成员管理不再放在空间设置里,设置里只留一句指引。
|
||||
|
||||
邀请弹窗只收用户名;成功、撤销、移除、失败都弹 toast。toast 在右下角,4 秒消失(带操作按钮的 10 秒),最多 4 条,成功 / 失败 / 提示用左侧色条区分,`aria-live`。被邀请方登录后每 8 秒轮询账号邀请,出现新邀请就弹「xxx 邀请你加入 yyy [接受]」,点「接受」直接加入并进入空间;账号设置里的邀请收件箱同样显示空间名与邀请人。空间名与邀请人放在邀请 payload 的加密内容里(`ChannelGrant.name / invitedBy`,均为可选字段),服务端不可见;旧邀请没有这两个字段时回退为「空间 + ID 前 8 位」。
|
||||
|
||||
## 14. 文件管理器交互
|
||||
|
||||
文件区支持单击选中、Ctrl/Command 多选、Shift 连选、双击预览、右键菜单和键盘 Enter/Delete/Ctrl+A/Escape。文件夹单击选中、双击或菜单“打开文件夹”进入;面包屑用于快速返回。文件行提供更多菜单按钮,触屏也能访问操作。
|
||||
@@ -349,9 +357,13 @@ Share 是独立的只读发布资源,不创建频道成员、threads 或席位
|
||||
|
||||
## 15. 频道中的文件消息与发送者
|
||||
|
||||
聊天时间线同时显示文本和已发布的文件,文件消息使用头像、发送者名称、时间和文件卡片,卡片提供预览和下载。当前账号发出的消息靠右,其他人的消息靠左;没有头像时显示名称首字。上传中显示进度和状态,受限文件不自动加载内容或媒体缩略图。
|
||||
聊天时间线采用 Discord 式布局:所有消息靠左,同一条左边线,左侧头像、右侧「名称 + 时间」一行与占满宽度的正文,不再区分自己和他人。同一发送者 5 分钟内连发的消息合并,只缩进对齐,悬停时在头像位置显示时间;悬停整行有淡底,右上角出现删除。文件消息是 360px 以内的文件卡片(图标、名称、大小与类型,右侧预览与下载);没有头像时显示名称首字。受限文件不自动加载内容或媒体缩略图。
|
||||
|
||||
发送者名称与头像从后端账号资料读取,回退固定用户名;设备名保留在提示中。资源作者由后端记录,昵称修改后其他设备同步更新展示。没有账号归属时回退设备来源。
|
||||
输入区是整体圆角框(左 + 附件、中间自增高输入、右发送),占位文案「给 #频道名 发消息」,Enter 发送、Shift+Enter 换行(输入法组合中不触发)。
|
||||
|
||||
**乐观发送**:点发送的同一帧就清空输入框并在时间线底部画出消息(半透明,aria-busy),后台再加密上传,不等后端;可以连续快速发送。worker 发布真实条目时带回 localId,在同一次状态更新里替换占位项,不闪烁、不重复。失败时占位项保留原文本并显示「发送失败 · 重试」,可删除;离线沿用本机发送队列。文件同理:确认上传选项后卡片立刻出现并显示进度。
|
||||
|
||||
发送者名称与头像从后端账号资料读取,回退固定用户名。资源作者由后端记录,昵称修改后其他设备同步更新展示。没有账号归属(未登录访客)时统一显示「访客」;界面里不再展示设备名,设备只在账号设置的「我的设备」里管理。
|
||||
|
||||
头像缩略图在 Worker 内生成,最大 64×64、编码后不超过 8 KB,仅使用 PNG/JPEG/WebP data URL;不使用第三方头像链接。圆形头像使用专门的 --radius-avatar token,消息气泡仍遵守现有圆角规范。
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ ws.onmessage = async (m) => {
|
||||
| 加密成帧、padding、IV、AAD | 发送队列(IndexedDB)、失败重发 |
|
||||
| 解密、完整性校验、乱序/重复/篡改处理 | 把事件渲染成界面 |
|
||||
| 链接生成与解析、降级防御 | 持久化链接和**终端密钥** |
|
||||
| 设备名加解密 | 复制、下载(把流写到文件)、预览 |
|
||||
| 日志记录解析 | **Cap 验证码**(见下文) |
|
||||
|
||||
## API
|
||||
@@ -67,7 +66,6 @@ ws.onmessage = async (m) => {
|
||||
| `session.link(origin, 'member' \| 'owner')` | 生成分享链接 |
|
||||
| `session.verifyChannelInfo({ id, mode })` | 核对服务端返回的频道信息,不一致抛 `ModeMismatchError` / `LinkError` |
|
||||
| `session.id` / `.mode` / `.role` / `.endpointSecret` / `.endpointHash` | 只读属性 |
|
||||
| `session.deviceName` | 设置后,加密的条目元数据里会带上设备名 |
|
||||
| `session.dispose()` | 尽力清零密钥材料(JS 无法保证擦除) |
|
||||
|
||||
### 发送
|
||||
@@ -91,7 +89,7 @@ ws.onmessage = async (m) => {
|
||||
| `snapshot` | 快照必须是单帧(约 1 MiB 以内),不能 GATED |
|
||||
| `itemId` | 重试和续传必须复用同一个 id |
|
||||
| `fromFrame` | 上传断点续传:从第 k 帧开始产出(字节与完整加密完全相同)。仅支持 `Blob` / `Uint8Array` |
|
||||
| `ts`, `dev` | 覆盖时间戳和设备名 |
|
||||
| `ts` | 覆盖时间戳 |
|
||||
|
||||
**GATED 的判断**:来源长度已知时创建计划时就确定;来源是流时,要产出第 0 帧之后 `plan.gated` 和 `frame0Headers` 才确定,请在拿到第 0 帧后再读取。
|
||||
|
||||
@@ -131,13 +129,12 @@ const { complete, nextIndex, meta } = await result;
|
||||
| 调用 | 说明 |
|
||||
|---|---|
|
||||
| `parseLogRecords(bytes)` / `parseLogRecord(bytes)` | 解析 `GET /log` 的响应或一条 WebSocket 消息 |
|
||||
| `session.sealLabel(name)` / `session.openLabel(blob, endpointHash)` | 设备名(`PUT /seats/me` 的请求体 / 席位列表里的 `label`) |
|
||||
| `parseLink(url)` / `buildLink(origin, parts)` | 链接 |
|
||||
| `splitEnvelopes(stream)` | 把「封装首尾相接」的字节流切成一个个封装 |
|
||||
|
||||
## 必须注意的几点
|
||||
|
||||
**1. 终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这台设备在这个频道里」,服务端按它登记独立设备席位以便认证和撤销;频道成员上限按账号数计算,同一账号的多台设备不重复占名额。不持久化的话,每次打开链接都会登记一个新设备终端。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。
|
||||
**1. 访客终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这个访客终端在这个频道里」,服务端按它登记访客以便认证和移除;登录账号以账号会话鉴权,不需要它,同一账号的多台设备也不重复占名额。访客不持久化的话,每次打开链接都会登记一个新访客终端。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。
|
||||
|
||||
**2. Worker。** `ChannelSession` 实例不能跨线程传递(它持有 `CryptoKey` 和闭包)。在 Worker 里用链接和 `endpointSecret` 重建它,主线程只传链接、帧字节和事件。
|
||||
|
||||
@@ -167,10 +164,10 @@ npm run vectors:check # 校验 test-vectors/vectors.json 是最新的
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `vectors.test.ts` | 逐字节复现 `test-vectors/` 里 node:crypto 独立生成的全部向量,含 17 个负例 |
|
||||
| `session.test.ts` | 创建、链接、角色、降级防御、收发、设备名、raw 模式、条目计划 |
|
||||
| `session.test.ts` | 创建、链接、角色、降级防御、收发、raw 模式、条目计划 |
|
||||
| `reader.test.ts` | 日志记录、重放、乱序、缺帧、FINAL 冲突、标志变化、模式隔离 |
|
||||
| `stream.test.ts` | 任意切分的字节流、流式解密、篡改/拼接/丢帧/截断、断点续传 |
|
||||
| `properties.test.ts` | 每个字节每一位的篡改、padding 档位、随机往返、来源块边界对齐、性能底线 |
|
||||
| `e2e.test.ts` | 真实服务端:HTTP、WebSocket、大文件、下载上限、上传与下载续传、设备名 |
|
||||
| `e2e.test.ts` | 真实服务端:HTTP、WebSocket、大文件、下载上限、上传与下载续传 |
|
||||
|
||||
另外对测试集做过**变异检验**:故意在实现里注入 10 类典型错误(AAD 少一字节、跳过零填充检查、去掉模式/条目 id 校验、base64 不查规范性、分块边界差一、IV 不含帧序号、重复帧不比较、FINAL 后不拒绝、链接不拒绝查询串密钥),测试都能变红。其中「分块边界差一」最初漏网,说明测试有缺口,已补上专门的边界对齐用例。
|
||||
@@ -38,11 +38,7 @@ export const Labels = {
|
||||
|
||||
/** AAD 前缀。 */
|
||||
export const AAD_FRAME = 'WD1';
|
||||
export const AAD_LABEL = 'WD1L';
|
||||
|
||||
/** 设备名总长上限(iv + 密文 + tag),与服务端 MaxSeatLabelBytes 一致。 */
|
||||
export const MAX_LABEL_BYTES = 256;
|
||||
export const LABEL_IV_SIZE = 12;
|
||||
|
||||
export const POW_NONCE_SIZE = 8;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AAD_LABEL, ID_SIZE, KEY_SIZE, LABEL_IV_SIZE, MAX_LABEL_BYTES, TAG_SIZE, type ChannelMode } from './constants.js';
|
||||
import { concat, fromBase64Url, randomBytes, toBase64Url, tryDecodeUtf8, utf8, wipe, type Bytes } from './bytes.js';
|
||||
import { CapabilityError, IntegrityError, LimitError, LinkError, ModeMismatchError } from './errors.js';
|
||||
import { ID_SIZE, KEY_SIZE, type ChannelMode } from './constants.js';
|
||||
import { randomBytes, toBase64Url, utf8, wipe, type Bytes } from './bytes.js';
|
||||
import { CapabilityError, LinkError, ModeMismatchError } from './errors.js';
|
||||
import { encodeEnvelope, sealFrame } from './frame.js';
|
||||
import { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256, type ChannelKeys } from './kdf.js';
|
||||
import { buildLink, parseLink, type ParsedLink } from './link.js';
|
||||
@@ -15,7 +15,7 @@ export interface ChannelSettings {
|
||||
join_policy?: 'open' | 'invite';
|
||||
allow_guests?: boolean;
|
||||
name?: string;
|
||||
seats?: number;
|
||||
max_members?: number;
|
||||
members_can_upload?: boolean;
|
||||
delete_policy?: 'owner_only' | 'own' | 'anyone';
|
||||
ttl_seconds?: number;
|
||||
@@ -331,68 +331,6 @@ export class ChannelSession {
|
||||
return decryptItemStream(this.#readerContext, source, opts);
|
||||
}
|
||||
|
||||
// ───────────────────────── 设备名(protocol §10) ─────────────────────────
|
||||
|
||||
/**
|
||||
* 加密设备名,作为席位名称 PUT 请求体。格式:iv(12) ‖ AES-GCM(K_label, 名称) ‖ tag(16),
|
||||
* AAD = "WD1L" ‖ channel_id ‖ endpoint_hash。raw 模式下设备名是明文 UTF-8。
|
||||
* @param opts.endpointHash 指定目标席位;所有者可给其他终端重命名。
|
||||
* @param opts.iv 仅用于测试向量,真实使用必须让库随机生成。
|
||||
*/
|
||||
async sealLabel(name: string, opts: { iv?: Uint8Array; endpointHash?: Uint8Array | string } = {}): Promise<Bytes> {
|
||||
const endpointHash = typeof opts.endpointHash === 'string'
|
||||
? fromBase64Url(opts.endpointHash, 32)
|
||||
: opts.endpointHash ?? this.endpointHash;
|
||||
if (!endpointHash || endpointHash.length !== 32) throw new RangeError('终端标识必须是 32 字节');
|
||||
const plain = utf8(name);
|
||||
if (this.mode === 'raw') {
|
||||
if (plain.length > MAX_LABEL_BYTES) throw new LimitError('设备名过长');
|
||||
return plain;
|
||||
}
|
||||
if (plain.length === 0) throw new RangeError('设备名不能为空');
|
||||
if (plain.length + LABEL_IV_SIZE + TAG_SIZE > MAX_LABEL_BYTES) {
|
||||
throw new LimitError(`设备名过长(最多 ${MAX_LABEL_BYTES - LABEL_IV_SIZE - TAG_SIZE} 字节 UTF-8)`);
|
||||
}
|
||||
const iv = opts.iv ?? randomBytes(LABEL_IV_SIZE);
|
||||
const sealed = await globalThis.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv as Bytes, additionalData: this.#labelAad(endpointHash), tagLength: 128 },
|
||||
(this.#keys as ChannelKeys).labelKey,
|
||||
plain,
|
||||
);
|
||||
return concat(iv, new Uint8Array(sealed));
|
||||
}
|
||||
|
||||
/** 解密席位列表里某个终端的设备名。endpointHash 可以是 base64url 文本或字节。 */
|
||||
async openLabel(blob: Uint8Array | string, endpointHash: Uint8Array | string): Promise<string> {
|
||||
const raw = typeof blob === 'string' ? fromBase64Url(blob) : blob;
|
||||
const hash = typeof endpointHash === 'string' ? fromBase64Url(endpointHash, 32) : endpointHash;
|
||||
if (!raw || !hash) throw new IntegrityError('bad_envelope');
|
||||
|
||||
if (this.mode === 'raw') {
|
||||
const text = tryDecodeUtf8(raw);
|
||||
if (text === null) throw new IntegrityError('bad_meta');
|
||||
return text;
|
||||
}
|
||||
if (raw.length < LABEL_IV_SIZE + TAG_SIZE + 1) throw new IntegrityError('bad_envelope');
|
||||
try {
|
||||
const plain = await globalThis.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: raw.slice(0, LABEL_IV_SIZE), additionalData: this.#labelAad(hash), tagLength: 128 },
|
||||
(this.#keys as ChannelKeys).labelKey,
|
||||
raw.slice(LABEL_IV_SIZE),
|
||||
);
|
||||
const text = tryDecodeUtf8(new Uint8Array(plain));
|
||||
if (text === null) throw new IntegrityError('bad_meta');
|
||||
return text;
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) throw e;
|
||||
throw new IntegrityError('auth_failed');
|
||||
}
|
||||
}
|
||||
|
||||
#labelAad(endpointHash: Uint8Array): Bytes {
|
||||
return concat(utf8(AAD_LABEL), this.idBytes, endpointHash);
|
||||
}
|
||||
|
||||
// ───────────────────────── 清理 ─────────────────────────
|
||||
|
||||
/** 尽力清零可以清零的字节并清空缓存。JS 无法保证擦除,这只是缩小暴露窗口。 */
|
||||
|
||||
@@ -114,7 +114,7 @@ async function createChannel(opts: Parameters<typeof ChannelSession.create>[0] =
|
||||
}
|
||||
|
||||
async function joinChannel(session: ChannelSession, role: AuthRole = 'read') {
|
||||
const res = await fetch(api(`/channels/${session.id}/seats`), { method: 'POST', headers: headersFor(session, role) });
|
||||
const res = await fetch(api(`/channels/${session.id}/join`), { method: 'POST', headers: headersFor(session, role) });
|
||||
expect(res.status, await res.clone().text()).toBe(200);
|
||||
}
|
||||
|
||||
@@ -390,38 +390,6 @@ suite('端到端(真实服务端)', () => {
|
||||
expect(Buffer.compare(concatAll([part1.bytes, part2]), content)).toBe(0);
|
||||
}, 60_000);
|
||||
|
||||
it('设备名:成员加密上传,所有者在席位列表里解开', async () => {
|
||||
const owner = await createChannel();
|
||||
const member = await ChannelSession.open(owner.link(ORIGIN));
|
||||
await joinChannel(member, 'read');
|
||||
|
||||
const blob = await member.sealLabel('我的 iPhone');
|
||||
const put = await fetch(api(`/channels/${owner.id}/seats/me`), {
|
||||
method: 'PUT',
|
||||
headers: { ...headersFor(member, 'read'), 'Content-Type': 'application/octet-stream' },
|
||||
body: blob,
|
||||
});
|
||||
expect(put.status).toBe(204);
|
||||
|
||||
const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: headersFor(owner, 'owner') });
|
||||
const seats = (await res.json()) as Array<{ endpoint_hash: string; label?: string }>;
|
||||
const mine = seats.find((s) => s.endpoint_hash === toBase64Url(member.endpointHash))!;
|
||||
expect(await owner.openLabel(mine.label!, mine.endpoint_hash)).toBe('我的 iPhone');
|
||||
// 服务端存的是密文
|
||||
expect(Buffer.from(mine.label!, 'base64url').toString('utf8')).not.toContain('iPhone');
|
||||
});
|
||||
|
||||
it('最长的设备名(228 字节)也被服务端接受', async () => {
|
||||
const owner = await createChannel();
|
||||
const blob = await owner.sealLabel('x'.repeat(228));
|
||||
const put = await fetch(api(`/channels/${owner.id}/seats/me`), {
|
||||
method: 'PUT',
|
||||
headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' },
|
||||
body: blob,
|
||||
});
|
||||
expect(put.status).toBe(204);
|
||||
});
|
||||
|
||||
it('raw 频道:匿名成员无需令牌即可读取,服务端看得到明文', async () => {
|
||||
const owner = await createChannel({ mode: 'raw' });
|
||||
const member = await ChannelSession.open(owner.link(ORIGIN));
|
||||
@@ -448,7 +416,7 @@ suite('端到端(真实服务端)', () => {
|
||||
const member = await ChannelSession.open(owner.link(ORIGIN));
|
||||
await joinChannel(member, 'read');
|
||||
|
||||
const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: { ...headersFor(member, 'read') } });
|
||||
const res = await fetch(api(`/channels/${owner.id}/guests`), { headers: { ...headersFor(member, 'read') } });
|
||||
expect(res.status).toBe(403);
|
||||
expect(() => headersFor(member, 'owner')).toThrow();
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { fromBase64Url, toBase64Url } from '../src/bytes.js';
|
||||
import {
|
||||
CapabilityError,
|
||||
ChannelSession,
|
||||
IntegrityError,
|
||||
LimitError,
|
||||
LinkError,
|
||||
ModeMismatchError,
|
||||
@@ -61,8 +60,8 @@ describe('创建 enc 频道', () => {
|
||||
});
|
||||
|
||||
it('设置原样放进请求体', async () => {
|
||||
const { request } = await ChannelSession.create({ settings: { seats: 3, delete_policy: 'anyone' } });
|
||||
expect(request.body.settings).toEqual({ seats: 3, delete_policy: 'anyone' });
|
||||
const { request } = await ChannelSession.create({ settings: { max_members: 3, delete_policy: 'anyone' } });
|
||||
expect(request.body.settings).toEqual({ max_members: 3, delete_policy: 'anyone' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -281,39 +280,6 @@ describe('发送和接收(enc)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('设备名', () => {
|
||||
it('所有者能读出成员的设备名,其他人换个终端哈希读不出', async () => {
|
||||
const { session: owner } = await ChannelSession.create();
|
||||
const member = await ChannelSession.open(owner.link(ORIGIN));
|
||||
const blob = await member.sealLabel('办公室电脑');
|
||||
|
||||
expect(await owner.openLabel(blob, member.endpointHash)).toBe('办公室电脑');
|
||||
expect(await owner.openLabel(toBase64Url(blob), toBase64Url(member.endpointHash))).toBe('办公室电脑');
|
||||
await expect(owner.openLabel(blob, owner.endpointHash)).rejects.toThrowError(IntegrityError);
|
||||
});
|
||||
|
||||
it('每次加密使用新的随机 IV', async () => {
|
||||
const { session } = await ChannelSession.create();
|
||||
const a = await session.sealLabel('x');
|
||||
const b = await session.sealLabel('x');
|
||||
expect(toHex(a.slice(0, 12))).not.toBe(toHex(b.slice(0, 12)));
|
||||
});
|
||||
|
||||
it('篡改后解不开', async () => {
|
||||
const { session } = await ChannelSession.create();
|
||||
const blob = await session.sealLabel('abc');
|
||||
blob[blob.length - 1]! ^= 1;
|
||||
await expect(session.openLabel(blob, session.endpointHash)).rejects.toThrowError(IntegrityError);
|
||||
});
|
||||
|
||||
it('raw 模式下设备名是明文', async () => {
|
||||
const { session } = await ChannelSession.create({ mode: 'raw' });
|
||||
const blob = await session.sealLabel('plain');
|
||||
expect(new TextDecoder().decode(blob)).toBe('plain');
|
||||
expect(await session.openLabel(blob, session.endpointHash)).toBe('plain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('raw 模式往返', () => {
|
||||
it('单帧:保留统一元数据,没有加密标志', async () => {
|
||||
const { session: owner } = await ChannelSession.create({ mode: 'raw' });
|
||||
|
||||
@@ -182,30 +182,6 @@ describe('raw 帧', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('设备名', () => {
|
||||
it('固定 IV 下的密文一致,并能解回', async () => {
|
||||
const session = await openVectorSession();
|
||||
for (const l of vectors.labels) {
|
||||
const sealed = await session.sealLabel(l.name, { iv: h(l.iv) });
|
||||
expect(toHex(sealed)).toBe(l.blob);
|
||||
expect(sealed.length).toBeLessThanOrEqual(256);
|
||||
expect(await session.openLabel(sealed, session.endpointHash)).toBe(l.name);
|
||||
}
|
||||
});
|
||||
|
||||
it('换一个终端的哈希就解不开(AAD 绑定终端)', async () => {
|
||||
const session = await openVectorSession();
|
||||
const sealed = await session.sealLabel('我的 iPhone');
|
||||
await expect(session.openLabel(sealed, new Uint8Array(32))).rejects.toThrowError(IntegrityError);
|
||||
});
|
||||
|
||||
it('超过 228 字节 UTF-8 时拒绝', async () => {
|
||||
const session = await openVectorSession();
|
||||
await expect(session.sealLabel('x'.repeat(229))).rejects.toThrow();
|
||||
await expect(session.sealLabel('x'.repeat(228))).resolves.toHaveLength(256);
|
||||
});
|
||||
});
|
||||
|
||||
describe('链接', () => {
|
||||
it('合法链接解析结果与向量一致', () => {
|
||||
for (const v of vectors.links.valid) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
/// <summary>该账号此刻在本频道有至少一个活动订阅(WebSocket/SSE)。</summary>
|
||||
[JsonPropertyName("online")] public required bool Online { get; init; }
|
||||
|
||||
/// <summary>该账号在本频道登记过的设备(终端)数;设备只是账号的属性,不是独立成员。</summary>
|
||||
[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<SeatInfo>))]
|
||||
[JsonSerializable(typeof(JoinResult))]
|
||||
[JsonSerializable(typeof(List<GuestInfo>))]
|
||||
[JsonSerializable(typeof(AppendResult))]
|
||||
[JsonSerializable(typeof(AccountRegisterRequest))]
|
||||
[JsonSerializable(typeof(AccountDeviceSigningRequest))]
|
||||
|
||||
@@ -12,6 +12,9 @@ public sealed class ChannelHub : IChannelEvents
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<Id16, Signal> _signals = [];
|
||||
|
||||
// 在线状态:频道 -> 账号 -> 账号设备 -> 活动订阅数。只记账号,访客没有可跨设备识别的身份,不参与。
|
||||
private readonly Dictionary<Id16, Dictionary<string, Dictionary<string, int>>> _presence = [];
|
||||
|
||||
public Subscription Subscribe(Id16 id)
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -22,9 +25,57 @@ public sealed class ChannelHub : IChannelEvents
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>登记一个活动订阅,释放返回值即下线。只供成员接口读取,不向订阅流推送,以免改变流的线格式。</summary>
|
||||
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<Signal> 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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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);
|
||||
|
||||
@@ -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<byte> endpointHash);
|
||||
void SetSeatLabel(Id16 channelId, ReadOnlySpan<byte> endpointHash, byte[] label);
|
||||
IReadOnlyList<Seat> ListSeats(Id16 channelId);
|
||||
|
||||
// 条目
|
||||
|
||||
@@ -56,7 +56,7 @@ public sealed partial class ChannelService
|
||||
tx.DenyAccount(id, username);
|
||||
return 0;
|
||||
});
|
||||
events.SeatsChanged(id);
|
||||
events.MembersChanged(id);
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChannelMember> 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)
|
||||
|
||||
@@ -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)
|
||||
/// <summary>
|
||||
/// 加入频道。账号成员只登记成员关系(同一账号的多台设备不增加人数,也不登记终端);
|
||||
/// 未登录访客登记终端,每个终端按一个访客成员计入上限。
|
||||
/// </summary>
|
||||
public JoinResult Join(Id16 id, Credentials creds, string? password = null)
|
||||
{
|
||||
var (joined, isNew) = store.Write(tx =>
|
||||
{
|
||||
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
var 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<SeatInfo> ListSeats(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
/// <summary>所有者查看访客(未登录终端)。账号成员按用户管理,其设备不对外列出。</summary>
|
||||
public IReadOnlyList<GuestInfo> ListGuests(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
RequireOwner(principal);
|
||||
return (IReadOnlyList<SeatInfo>)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<GuestInfo>)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)
|
||||
/// <summary>所有者移除一个访客终端并禁止它再加入。账号成员改用撤销邀请(RevokeInvitation)。</summary>
|
||||
public void ReleaseGuest(Id16 id, Credentials creds, byte[] endpointHash)
|
||||
{
|
||||
store.Write(tx =>
|
||||
{
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
|
||||
RequireOwner(principal);
|
||||
if (!tx.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,
|
||||
|
||||
@@ -60,7 +60,7 @@ public sealed class ChannelInfo
|
||||
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
|
||||
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
|
||||
/// <summary>v5: 账号成员数加匿名访客终端数,不统计账号设备数。</summary>
|
||||
[JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; }
|
||||
[JsonPropertyName("members_used")] public required int MembersUsed { get; init; }
|
||||
|
||||
/// <summary>从该 seq 起的续传有效;0 < from < oldest_seq 的续传会得到 410,需从 0 全量同步。</summary>
|
||||
[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; }
|
||||
/// <summary>加入后的成员人数,同一账号的其他终端不增加此值。</summary>
|
||||
[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
|
||||
/// <summary>未登录访客的一次加入(终端)。账号成员按用户管理,不在这里出现。</summary>
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
/// <summary>频道内的终端登记(账号设备或访客终端)。设备只是账号的属性,不是成员;不再有单独的设备名。</summary>
|
||||
public sealed record Seat(byte[] EndpointHash, long JoinedAt, string? AccountName = null, string? AccountDeviceId = null);
|
||||
public sealed record ChannelInvitation(Id16 ChannelId, string Username, string Payload, long CreatedAt);
|
||||
public sealed record ChannelMember(string Username, string Role, long JoinedAt);
|
||||
public sealed record ChannelKeyPackage(Id16 ChannelId, string Username, string Role, string Payload, long CreatedAt);
|
||||
@@ -101,7 +102,13 @@ public sealed record StoredFrame(
|
||||
public sealed record LogEntry(long Seq, LogKind Kind, Id16 ItemId, uint FrameIndex, byte Aux, long CreatedAt);
|
||||
|
||||
/// <summary>一次请求验证通过后的身份。</summary>
|
||||
public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null);
|
||||
public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null)
|
||||
{
|
||||
/// <summary>受限下载的计数单位:账号按用户计(设备只是账号的属性),访客按终端计。</summary>
|
||||
public byte[] GrantKey => AccountName is null
|
||||
? EndpointHash
|
||||
: System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes("webdrop/grant/v1/" + AccountName));
|
||||
}
|
||||
|
||||
/// <summary>请求携带的凭据,尚未验证。</summary>
|
||||
public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash, string? AccountName = null, string? AccountDeviceId = null,
|
||||
|
||||
@@ -11,7 +11,7 @@ public sealed class ServerLimits
|
||||
public long MaxWorkspaceBytes { get; set; } = 1L << 40;
|
||||
public long MaxWorkspaceChannelBytes { get; set; } = 10L << 30;
|
||||
/// <summary>最大账号成员数。属性名和 info 字段保持旧线格式兼容。</summary>
|
||||
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; }
|
||||
/// <summary>成员人数上限;线字段名保留为 seats 以兼容已有频道设置。</summary>
|
||||
[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),
|
||||
};
|
||||
|
||||
@@ -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<ChannelInvitation> ListInvitations(Id16 id) => Many("SELECT channel_id,username,payload,created_at FROM channel_invitations WHERE channel_id=@id", MapInvitation, ("@id", id.ToArray()));
|
||||
public IReadOnlyList<ChannelInvitation> 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<ChannelInvitation> ListUserInvitations(string username) => Many("SELECT i.channel_id,i.username,i.payload,i.created_at FROM channel_invitations i JOIN channels c ON c.id=i.channel_id WHERE i.username=@u AND c.expires_at>@now AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id=i.channel_id AND m.username=i.username)", MapInvitation, ("@u", username), ("@now", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()));
|
||||
public void DeleteInvitation(Id16 id, string username) => Run("DELETE FROM channel_invitations WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username));
|
||||
public void PutKeyPackage(Id16 id, string username, string payload, long createdAt) => Run(
|
||||
@@ -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<byte> 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<byte> 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<byte> 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<Seat> ListSeats(Id16 channelId) => Many(
|
||||
"SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c ORDER BY joined_at, endpoint_hash",
|
||||
MapSeat, ("@c", channelId.ToArray()));
|
||||
|
||||
@@ -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<string> Register(string username)
|
||||
{
|
||||
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
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<ServerFixture>
|
||||
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<ServerFixture>
|
||||
}
|
||||
|
||||
[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<ServerFixture>
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HttpResponseMessage> JoinAsync(Chan ch, Who who) =>
|
||||
await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who));
|
||||
await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/join", who));
|
||||
|
||||
public async Task<HttpResponseMessage> AppendAsync(
|
||||
Chan ch,
|
||||
|
||||
@@ -116,13 +116,13 @@ public class StreamingTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
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<ServerFixture>
|
||||
}
|
||||
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<ServerFixture>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -47,18 +47,18 @@ public sealed class WorkspaceApiTests(ServerFixture server) : IClassFixture<Serv
|
||||
[Fact]
|
||||
public async Task AdvertisedDefaultsRespectDeploymentLimitsAndMatchCreation()
|
||||
{
|
||||
await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxSeats"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" });
|
||||
await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxMembers"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" });
|
||||
await isolated.InitializeAsync();
|
||||
using var infoResponse = await isolated.Http.GetAsync("/api/v1/info"); var info = await ReadJsonAsync(infoResponse);
|
||||
var defaults = info.GetProperty("defaults").GetProperty("channel");
|
||||
Assert.Equal("enc", info.GetProperty("defaults").GetProperty("channel_mode").GetString());
|
||||
Assert.Equal(3, defaults.GetProperty("seats").GetInt32()); Assert.Equal(3600, defaults.GetProperty("ttl_seconds").GetInt32());
|
||||
Assert.Equal(3, defaults.GetProperty("max_members").GetInt32()); Assert.Equal(3600, defaults.GetProperty("ttl_seconds").GetInt32());
|
||||
var ch = new Chan(NewId(), ChannelMode.Enc);
|
||||
using var create = isolated.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(new { id = ch.Id, mode = "enc", settings = new { name = "Limited space", seats = 3, ttl_seconds = 3600 }, read_hash = B64(TokenHash.Compute(ch.ReadToken)), owner_hash = B64(TokenHash.Compute(ch.OwnerToken)), write_hash = B64(TokenHash.Compute(ch.WriteToken)) }));
|
||||
using var create = isolated.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(new { id = ch.Id, mode = "enc", settings = new { name = "Limited space", max_members = 3, ttl_seconds = 3600 }, read_hash = B64(TokenHash.Compute(ch.ReadToken)), owner_hash = B64(TokenHash.Compute(ch.OwnerToken)), write_hash = B64(TokenHash.Compute(ch.WriteToken)) }));
|
||||
using var result = await isolated.Http.SendAsync(create); Assert.Equal(HttpStatusCode.Created, result.StatusCode);
|
||||
var settings = (await ReadJsonAsync(result)).GetProperty("settings");
|
||||
Assert.Equal("Limited space", settings.GetProperty("name").GetString());
|
||||
Assert.Equal(defaults.GetProperty("seats").GetInt32(), settings.GetProperty("seats").GetInt32());
|
||||
Assert.Equal(defaults.GetProperty("max_members").GetInt32(), settings.GetProperty("max_members").GetInt32());
|
||||
Assert.Equal(defaults.GetProperty("ttl_seconds").GetInt32(), settings.GetProperty("ttl_seconds").GetInt32());
|
||||
}
|
||||
}
|
||||
@@ -66,8 +66,8 @@ public class ChannelServiceTests : IDisposable
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var info = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp));
|
||||
Assert.Equal(1, info.SeatsUsed);
|
||||
Assert.Equal(2, info.Settings.Seats);
|
||||
Assert.Equal(1, info.MembersUsed);
|
||||
Assert.Equal(2, info.Settings.MaxMembers);
|
||||
Assert.Equal(1, info.OldestSeq);
|
||||
Assert.Equal(0, info.LastSeq);
|
||||
}
|
||||
@@ -99,38 +99,36 @@ public class ChannelServiceTests : IDisposable
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var a = Svc.Join(ch.Id, ch.Reader(_memberEp));
|
||||
var again = Svc.Join(ch.Id, ch.Reader(_memberEp));
|
||||
Assert.Equal(a.EndpointHash, again.EndpointHash);
|
||||
Assert.Equal(2, again.SeatsUsed);
|
||||
Assert.Equal(2, again.SeatsMax);
|
||||
Assert.Equal(a.JoinedAt, again.JoinedAt);
|
||||
Assert.Equal(2, again.MembersUsed);
|
||||
Assert.Equal(2, again.MembersMax);
|
||||
|
||||
AssertCode(ErrorCode.SeatsFull, () => 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);
|
||||
|
||||
@@ -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<WebDropException>(() => s.Validate(new ServerLimits()));
|
||||
Assert.Equal(ErrorCode.BadRequest, ex.Code);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Splash } from './Splash';
|
||||
import { AccountScreen } from '@/features/home/AccountScreen';
|
||||
import { AccountDock } from '@/features/home/AccountDock';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { ToastHost } from './ToastHost';
|
||||
import { useInvitationNotices } from './useInvitationNotices';
|
||||
|
||||
const SEEN_KEY = 'wd.splash';
|
||||
|
||||
@@ -27,6 +29,7 @@ export function App() {
|
||||
const username = useAccount((s) => s.username);
|
||||
const accountHydrated = useAccount((s) => s.hydrated);
|
||||
const refreshAccount = useAccount((s) => s.refresh);
|
||||
useInvitationNotices(username);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => { void refreshAccount(); }, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
@@ -82,6 +85,7 @@ export function App() {
|
||||
<>
|
||||
{screen}
|
||||
{username ? <AccountDock /> : null}
|
||||
<ToastHost />
|
||||
{splash ? <Splash ready={ready} onDone={onSplashDone} /> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
.host {
|
||||
position: fixed;
|
||||
right: max(var(--space-4), env(safe-area-inset-right));
|
||||
bottom: max(var(--space-4), env(safe-area-inset-bottom));
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
width: min(360px, calc(100vw - var(--space-6)));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3);
|
||||
border-inline-start: 3px solid var(--busy);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-pop);
|
||||
font-size: 13px;
|
||||
pointer-events: auto;
|
||||
animation: toast-in var(--duration-enter) ease-out;
|
||||
}
|
||||
|
||||
.toast svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ok {
|
||||
border-inline-start-color: var(--ok);
|
||||
}
|
||||
|
||||
.ok svg {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.error {
|
||||
border-inline-start-color: var(--danger);
|
||||
}
|
||||
|
||||
.error svg {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.info svg {
|
||||
color: var(--busy);
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.close {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
translate: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useToast } from '@/store/toast';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import styles from './ToastHost.module.css';
|
||||
|
||||
const ICON = { ok: 'check', error: 'warn', info: 'bell' } as const;
|
||||
|
||||
export function ToastHost() {
|
||||
const toasts = useToast((s) => s.toasts);
|
||||
const dismiss = useToast((s) => s.dismiss);
|
||||
return <div className={styles.host} aria-live="polite">
|
||||
{toasts.map((toast) => <div key={toast.id} className={`${styles.toast} ${styles[toast.kind]}`} role={toast.kind === 'error' ? 'alert' : 'status'}>
|
||||
<Icon name={ICON[toast.kind]} size={18} />
|
||||
<span className={styles.text}>{toast.text}</span>
|
||||
{toast.action ? <Button variant="ghost" onClick={() => { toast.action?.run(); dismiss(toast.id); }}>{toast.action.label}</Button> : null}
|
||||
<Button className={styles.close} variant="ghost" icon="close" label="关闭通知" onClick={() => dismiss(toast.id)} />
|
||||
</div>)}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { accountApi, type AccountInvitation } from '@/core-bridge/account';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { useToast } from '@/store/toast';
|
||||
import { channelPath, navigate } from './navigation';
|
||||
|
||||
const POLL_MS = 8000;
|
||||
|
||||
export function invitationLabel(row: AccountInvitation): string {
|
||||
return row.channelName || `空间 ${row.channel_id.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
async function accept(row: AccountInvitation): Promise<void> {
|
||||
try {
|
||||
await accountApi.acceptInvitation(row.channel_id);
|
||||
await useChannels.getState().refresh();
|
||||
useToast.getState().push({ kind: 'ok', text: `已加入 ${invitationLabel(row)}` });
|
||||
navigate(channelPath(row.channel_id));
|
||||
} catch (cause) {
|
||||
useToast.getState().push({ kind: 'error', text: `加入失败:${errorMessage(cause)}` });
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录后轮询账号邀请,出现新邀请就弹通知,可直接接受 */
|
||||
export function useInvitationNotices(username: string | null): void {
|
||||
const seen = useRef<Set<string> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!username) { seen.current = null; return; }
|
||||
let stopped = false;
|
||||
async function poll() {
|
||||
try {
|
||||
const rows = await accountApi.invitations();
|
||||
if (stopped) return;
|
||||
const known = seen.current;
|
||||
seen.current = new Set(rows.map((row) => row.channel_id));
|
||||
const fresh = known ? rows.filter((row) => !known.has(row.channel_id)) : rows;
|
||||
for (const row of fresh) {
|
||||
useToast.getState().push({ kind: 'info', text: row.invitedBy ? `${row.invitedBy} 邀请你加入 ${invitationLabel(row)}` : `你收到了加入 ${invitationLabel(row)} 的邀请`,
|
||||
action: { label: '接受', run: () => { void accept(row); } } });
|
||||
}
|
||||
} catch { /* 下一轮再试,不打扰用户 */ }
|
||||
}
|
||||
void poll();
|
||||
const timer = window.setInterval(() => { void poll(); }, POLL_MS);
|
||||
return () => { stopped = true; window.clearInterval(timer); };
|
||||
}, [username]);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
|
||||
|
||||
export interface AccountStatus { username: string | null; revision: number; remembered?: boolean; personal?: PersonalProfile; error?: string; unlockUsername?: string }
|
||||
export type AccountDevice = AccountDeviceBody;
|
||||
export type AccountInvitation = InvitationBody;
|
||||
export type AccountInvitation = InvitationBody & { channelName?: string; invitedBy?: string };
|
||||
|
||||
export const accountApi = {
|
||||
register: (username: string, password: string, remember = true) => callWorker<AccountStatus>('registerAccount', { username, password, remember, apiBase: API_BASE }),
|
||||
|
||||
@@ -11,9 +11,7 @@ export interface WorkspaceApi {
|
||||
createChannel(opts: CreateOptions, captchaToken?: string): Promise<ChannelInfo>;
|
||||
/** 使用现有空间 settings 接口更新空间设置。 */
|
||||
updateChannelSettings(patch: ChannelSettingsPatch): Promise<void>;
|
||||
renameDevice(endpointHash: string, name: string): Promise<void>;
|
||||
releaseDevice(endpointHash: string): Promise<void>;
|
||||
/** 通过链接加入:先 GET 空间信息预览,再 POST seats */
|
||||
/** 通过链接加入:先 GET 空间信息预览,再 POST join */
|
||||
joinChannel(link: ParsedChannelLink, password?: string): Promise<ChannelInfo>;
|
||||
inviteUser(username: string): Promise<void>;
|
||||
listInvitations(): Promise<AccountInvitation[]>;
|
||||
@@ -24,8 +22,8 @@ export interface WorkspaceApi {
|
||||
|
||||
/** 订阅某个空间的事件流(对应追加日志的推送),返回取消订阅函数 */
|
||||
connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void;
|
||||
sendText(text: string, threadId?: string): Promise<void>;
|
||||
sendFiles(files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string): Promise<void>;
|
||||
sendText(text: string, threadId?: string, localId?: string): Promise<void>;
|
||||
sendFiles(files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string, localIds?: string[]): Promise<void>;
|
||||
createThread(name: string, groupId?: string): Promise<string>;
|
||||
createGroup(name: string): Promise<string>;
|
||||
renameGroup(id: string, name: string): Promise<void>;
|
||||
|
||||
@@ -3,15 +3,16 @@ import { supportsApiContract } from './contract';
|
||||
|
||||
describe('API contract compatibility', () => {
|
||||
it('accepts compatible SemVer releases in the supported major', () => {
|
||||
expect(supportsApiContract('6.0.0')).toBe(true);
|
||||
expect(supportsApiContract('6.8.2')).toBe(true);
|
||||
expect(supportsApiContract('6.2.0-rc.1')).toBe(true);
|
||||
expect(supportsApiContract('7.0.0')).toBe(true);
|
||||
expect(supportsApiContract('7.8.2')).toBe(true);
|
||||
expect(supportsApiContract('7.2.0-rc.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a breaking major and malformed versions', () => {
|
||||
expect(supportsApiContract('3.0.0')).toBe(false);
|
||||
expect(supportsApiContract('4.0.0')).toBe(false);
|
||||
expect(supportsApiContract('5.0.0')).toBe(false);
|
||||
expect(supportsApiContract('6.1.0')).toBe(false);
|
||||
expect(supportsApiContract('01.0.0')).toBe(false);
|
||||
expect(supportsApiContract(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const SUPPORTED_API_CONTRACT_MAJOR = 6;
|
||||
export const SUPPORTED_API_CONTRACT_MAJOR = 7;
|
||||
|
||||
const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ export interface SavedResource {
|
||||
kind: 'channel' | 'share';
|
||||
/** 服务端空间设置名称的本地缓存,打开空间时会用最新设置刷新。 */
|
||||
name?: string;
|
||||
/** 本终端在席位列表中的自定义显示名称。 */
|
||||
deviceLabel?: string;
|
||||
mode: 'enc' | 'raw';
|
||||
/** Keys stay in IndexedDB. The page URL and localStorage never retain them. */
|
||||
link: string;
|
||||
@@ -30,7 +28,7 @@ export interface SavedResource {
|
||||
mine?: boolean;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
seats: number;
|
||||
maxMembers: number;
|
||||
state?: 'draft' | 'sealed';
|
||||
maxVisitors?: number;
|
||||
maxDownloads?: number | null;
|
||||
@@ -137,7 +135,7 @@ export async function importResources(resources: SavedResource[]): Promise<void>
|
||||
const existing = await tx.store.get(resource.id);
|
||||
await tx.store.put({ ...resource, localScope: resourceScope,
|
||||
endpointSecret: existing?.endpointSecret ?? crypto.getRandomValues(new Uint8Array(32)),
|
||||
deviceLabel: existing?.deviceLabel, localItems: existing?.localItems,
|
||||
localItems: existing?.localItems,
|
||||
pendingManifestId: existing?.pendingManifestId,
|
||||
needsJoin: existing?.needsJoin ?? (!existing && resource.kind === 'channel'),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const messages: Record<string, string> = {
|
||||
not_found: '内容不存在或已过期。', gone: '内容已删除、下载次数已用完或续传已失效。',
|
||||
seats_full: '空间成员人数已满,请联系所有者。', not_joined: '你已被移出此空间。',
|
||||
members_full: '空间成员人数已满,请联系所有者。', not_joined: '你已被移出此空间。',
|
||||
visitors_full: '分享的访客名额已满。', unauthorized: '链接权限无效,请使用完整链接重新打开。',
|
||||
forbidden: '此链接没有执行此操作的权限。', sealed: '分享已封存,无法再修改。',
|
||||
too_large: '内容超过服务端容量或条目数量限制。', rate_limited: '请求过于频繁,请稍后重试。',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface VaultResource {
|
||||
expiresAt: number;
|
||||
creatorAccount?: string;
|
||||
name?: string;
|
||||
seats: number;
|
||||
maxMembers: number;
|
||||
ttlSeconds?: number;
|
||||
maxDownloads?: number | null;
|
||||
maxVisitors?: number;
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface PublishedFile {
|
||||
mime: string;
|
||||
size: number;
|
||||
ts: number;
|
||||
deviceId: string;
|
||||
gated: boolean;
|
||||
}
|
||||
export type ResourceControl = { op: 'file.published'; item: PublishedFile };
|
||||
|
||||
@@ -17,8 +17,6 @@ function reportChannelUnavailable(id: string, onEvent: (event: WorkspaceEvent) =
|
||||
export const realApi: WorkspaceApi = {
|
||||
createChannel: (options: CreateOptions, captchaToken?: string) => callWorker<ChannelInfo>('createChannel', { options, captchaToken, apiBase: API_BASE }),
|
||||
updateChannelSettings: (patch: ChannelSettingsPatch) => callWorker<void>('updateChannelSettings', { id: currentChannelId(), patch, apiBase: API_BASE }),
|
||||
renameDevice: (endpointHash, name) => callWorker<void>('renameDevice', { id: currentChannelId(), endpointHash, name, apiBase: API_BASE }),
|
||||
releaseDevice: (endpointHash) => callWorker<void>('releaseDevice', { id: currentChannelId(), endpointHash, apiBase: API_BASE }),
|
||||
joinChannel: (link: ParsedChannelLink, password?: string) => callWorker<ChannelInfo>('joinChannel', { link, password, apiBase: API_BASE }),
|
||||
inviteUser: (username) => callWorker<void>('inviteChannelUser', { id: currentChannelId(), username, apiBase: API_BASE }),
|
||||
listInvitations: () => callWorker<AccountInvitation[]>('listChannelInvitations', { id: currentChannelId(), apiBase: API_BASE }),
|
||||
@@ -37,8 +35,8 @@ export const realApi: WorkspaceApi = {
|
||||
};
|
||||
},
|
||||
|
||||
sendText: (text, threadId) => callWorker<void>('sendText', { id: currentChannelId(), text, threadId, apiBase: API_BASE }),
|
||||
sendFiles: (files, options, threadId, folderId) => callWorker<void>('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, apiBase: API_BASE }),
|
||||
sendText: (text, threadId, localId) => callWorker<void>('sendText', { id: currentChannelId(), text, threadId, localId, apiBase: API_BASE }),
|
||||
sendFiles: (files, options, threadId, folderId, localIds) => callWorker<void>('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, localIds, apiBase: API_BASE }),
|
||||
createThread: (name, groupId) => callWorker<string>('createThread', { id: currentChannelId(), name, groupId, apiBase: API_BASE }),
|
||||
createGroup: (name) => callWorker<string>('createGroup', { id: currentChannelId(), name, apiBase: API_BASE }),
|
||||
renameGroup: (groupId, name) => callWorker<void>('renameGroup', { id: currentChannelId(), groupId, name, apiBase: API_BASE }),
|
||||
|
||||
@@ -3,6 +3,6 @@ import type { CreateOptions } from './types';
|
||||
|
||||
export function creationOptions(defaults: DefaultsBody): CreateOptions {
|
||||
const settings = defaults.channel;
|
||||
return { mode: defaults.channel_mode, seats: settings.seats, ttlSeconds: settings.ttl_seconds, membersCanUpload: settings.members_can_upload,
|
||||
return { mode: defaults.channel_mode, maxMembers: settings.max_members, ttlSeconds: settings.ttl_seconds, membersCanUpload: settings.members_can_upload,
|
||||
fileMaxDownloads: settings.file_max_downloads, joinPolicy: settings.join_policy, allowGuests: settings.allow_guests };
|
||||
}
|
||||
@@ -1,19 +1,8 @@
|
||||
import type { ChannelMode, WorkspaceChannel, WorkspaceGroup, WorkspaceFolder, WorkspaceState, AccountProfile } from './generated/api';
|
||||
|
||||
export type DeviceKind = 'desktop' | 'phone' | 'tablet';
|
||||
export type ConnectionState = 'online' | 'reconnecting' | 'offline';
|
||||
export type PreviewKind = 'image' | 'video' | 'audio' | 'text' | 'archive' | 'none';
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: DeviceKind;
|
||||
self?: boolean;
|
||||
/** 正在编辑便签 */
|
||||
editing?: boolean;
|
||||
joinedAt?: number;
|
||||
}
|
||||
|
||||
/** 常驻空间:自己多台设备之间长期同步 */
|
||||
export interface ChannelInfo {
|
||||
dataSource?: 'server' | 'cache';
|
||||
@@ -26,7 +15,7 @@ export interface ChannelInfo {
|
||||
name?: string;
|
||||
mode: ChannelMode;
|
||||
role: 'owner' | 'member';
|
||||
seats: number;
|
||||
maxMembers: number;
|
||||
membersUsed?: number;
|
||||
membersCanUpload?: boolean;
|
||||
ttlSeconds?: number;
|
||||
@@ -48,7 +37,7 @@ export interface CreateOptions {
|
||||
joinPassword?: string;
|
||||
name?: string;
|
||||
mode: ChannelMode;
|
||||
seats: number;
|
||||
maxMembers: number;
|
||||
ttlSeconds: number;
|
||||
membersCanUpload: boolean;
|
||||
fileMaxDownloads?: number;
|
||||
@@ -65,7 +54,7 @@ export interface ChannelSettingsPatch {
|
||||
allowGuests?: boolean;
|
||||
joinPassword?: string;
|
||||
name?: string;
|
||||
seats?: number;
|
||||
maxMembers?: number;
|
||||
membersCanUpload?: boolean;
|
||||
ttlSeconds?: number;
|
||||
fileMaxDownloads?: number;
|
||||
@@ -111,10 +100,11 @@ interface ItemBase {
|
||||
author?: SenderInfo;
|
||||
id: string;
|
||||
ts: number;
|
||||
deviceId: string;
|
||||
state: ItemState;
|
||||
threadId?: string;
|
||||
folderId?: string;
|
||||
/** 乐观渲染:本地占位项的 id,worker 发布真实条目时带回,reduce 据此替换占位项 */
|
||||
localId?: string;
|
||||
}
|
||||
|
||||
export type SenderInfo = Pick<AccountProfile, 'username' | 'displayName'> & { avatar?: string };
|
||||
@@ -123,8 +113,8 @@ export interface TextItem extends ItemBase {
|
||||
kind: 'text';
|
||||
text: string;
|
||||
editedAt?: number;
|
||||
/** 另一台设备同时改过(后写者胜,见 ui-design §8) */
|
||||
conflict?: { deviceId: string; text: string };
|
||||
/** 另一位用户同时改过(后写者胜,见 ui-design §8) */
|
||||
conflict?: { author?: SenderInfo; text: string };
|
||||
}
|
||||
|
||||
export interface FileItem extends ItemBase {
|
||||
@@ -144,14 +134,14 @@ export interface FileItem extends ItemBase {
|
||||
* 客户端据此把它们折叠成同一个文件的历史。后端不解析 meta。
|
||||
*/
|
||||
versions?: FileVersion[];
|
||||
/** 当前版本是别的设备改的,而你还没看过 */
|
||||
/** 当前版本是别人改的,而你还没看过 */
|
||||
unseen?: boolean;
|
||||
}
|
||||
|
||||
export interface FileVersion {
|
||||
id: string;
|
||||
ts: number;
|
||||
deviceId: string;
|
||||
author?: SenderInfo;
|
||||
size: number;
|
||||
}
|
||||
|
||||
@@ -181,7 +171,6 @@ export type WorkspaceEvent =
|
||||
| { t: 'ready' }
|
||||
| { t: 'connection'; state: ConnectionState }
|
||||
| { t: 'channel'; info: ChannelInfo }
|
||||
| { t: 'devices'; devices: Device[] }
|
||||
| { t: 'item'; item: Item }
|
||||
| { t: 'file-catalog'; item: FileItem }
|
||||
| { t: 'removed'; id: string }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { setApiBase, getServerInfo } from './worker/http';
|
||||
import { listPasskeys, removePasskey, beginPasskeyRegistration, finishPasskeyRegistration, beginPasskeyRecovery, finishPasskeyRecovery, beginPasskeyLogin, finishPasskeyLogin, unlockPasskeyLogin, clearPasskeyState } from './worker/passkeys';
|
||||
import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems';
|
||||
import { createChannel, forgetChannel, joinChannel, listChannels, releaseDevice, renameDevice, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
|
||||
import { createChannel, forgetChannel, joinChannel, listChannels, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
|
||||
import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend';
|
||||
import { stopChannel } from './worker/channelTransport';
|
||||
import { addShareFiles, addShareText, editShareText, removeShareItem } from './worker/shareDraft';
|
||||
@@ -68,8 +68,6 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
|
||||
case 'createFolder': return createFolder(id, String(payload.threadId ?? ''), String(payload.name ?? ''), typeof payload.parentId === 'string' ? payload.parentId : undefined);
|
||||
case 'renameFolder': return renameFolder(id, String(payload.folderId ?? ''), String(payload.name ?? ''), String(payload.threadId ?? ''));
|
||||
case 'moveItems': return moveItems(id, payload.itemIds as string[], String(payload.threadId ?? ''), typeof payload.folderId === 'string' ? payload.folderId : undefined);
|
||||
case 'renameDevice': return renameDevice(id, String(payload.endpointHash ?? ''), String(payload.name ?? ''));
|
||||
case 'releaseDevice': return releaseDevice(id, String(payload.endpointHash ?? ''));
|
||||
case 'joinChannel': return joinChannel(payload as Parameters<typeof joinChannel>[0]);
|
||||
case 'previewChannel': return previewChannel(payload.link as Parameters<typeof previewChannel>[0]);
|
||||
case 'channelMembers': return channelMembers(id);
|
||||
@@ -77,8 +75,8 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
|
||||
case 'forgetChannel': return forgetChannel(id);
|
||||
case 'connectChannel': void connectChannelResource(id); return true;
|
||||
case 'stopChannel': stopChannel(id); return true;
|
||||
case 'sendText': return sendText(id, String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined);
|
||||
case 'sendFiles': return sendFiles(id, payload.files as File[], payload.options as Parameters<typeof sendFiles>[2] | undefined, typeof payload.threadId === 'string' ? payload.threadId : undefined, typeof payload.folderId === 'string' ? payload.folderId : undefined);
|
||||
case 'sendText': return sendText(id, String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined, undefined, typeof payload.localId === 'string' ? payload.localId : undefined);
|
||||
case 'sendFiles': return sendFiles(id, payload.files as File[], payload.options as Parameters<typeof sendFiles>[2] | undefined, typeof payload.threadId === 'string' ? payload.threadId : undefined, typeof payload.folderId === 'string' ? payload.folderId : undefined, Array.isArray(payload.localIds) ? payload.localIds as string[] : undefined);
|
||||
case 'removeChannelItem': return removeChannelItem(id, String(payload.itemId));
|
||||
case 'getChannelBlob': return getChannelBlob(id, String(payload.itemId), typeof payload.versionId === 'string' ? payload.versionId : undefined);
|
||||
case 'updateSnapshot': return updateSnapshot(id, String(payload.text ?? ''));
|
||||
|
||||
@@ -5,7 +5,7 @@ import { setAccountIdentity, requireAccount } from './accountSession';
|
||||
import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys, generateAccountSigningKeys } from './accountCrypto';
|
||||
import { mergeVault, validateVault, type VaultProfile } from './accountVault';
|
||||
import { joinChannel } from './channelResources';
|
||||
import { openChannelGrant, sealChannelGrant } from './channelGrant';
|
||||
import { openChannelGrant, peekChannelGrant, sealChannelGrant } from './channelGrant';
|
||||
import { recoverAccountChannels } from './accountChannels';
|
||||
import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, saveDeviceName, saveLoginFields, forgetLoginFields, savedLoginFields } from './accountPersistence';
|
||||
import type { AccountVaultBody, AccountSaltBody, AccountSessionBody, PublicKeyBody, InvitationBody, InvitationCryptoPlan, AccountDeviceBody, AccountRegisterRequest, AccountSessionRequest } from '../generated/api';
|
||||
@@ -26,7 +26,7 @@ interface AccountSession {
|
||||
controller: AbortController; dirty: boolean; syncing?: Promise<void>; error?: string;
|
||||
timer?: ReturnType<typeof setTimeout>; polling?: ReturnType<typeof setInterval>;
|
||||
}
|
||||
export type Invitation = InvitationBody;
|
||||
export type Invitation = InvitationBody & { channelName?: string; invitedBy?: string };
|
||||
let account: AccountSession | undefined;
|
||||
export function unlockedAccount() {
|
||||
if (!account) throw new Error('请先登录账号');
|
||||
@@ -246,7 +246,13 @@ export async function revokeAccountDevice(id: string): Promise<void> {
|
||||
if ((await localAccountDevice()).id === id) await logoutAccount();
|
||||
}
|
||||
export async function listAccountInvitations(): Promise<Invitation[]> {
|
||||
requireAccount(); return apiFetch('/accounts/invitations').then((r) => r.json() as Promise<Invitation[]>);
|
||||
requireAccount();
|
||||
const rows = await apiFetch('/accounts/invitations').then((r) => r.json() as Promise<InvitationBody[]>);
|
||||
const session = account;
|
||||
return Promise.all(rows.map(async (row): Promise<Invitation> => {
|
||||
const meta = session ? await peekChannelGrant(row.payload, session.username, row.channel_id, session.profile.keys.privateKey) : {};
|
||||
return { ...row, ...(meta.name ? { channelName: meta.name } : {}), ...(meta.invitedBy ? { invitedBy: meta.invitedBy } : {}) };
|
||||
}));
|
||||
}
|
||||
export async function acceptInvitation(channelId: string): Promise<void> {
|
||||
if (!account) throw new Error('请先登录账号');
|
||||
@@ -262,7 +268,7 @@ export async function inviteChannelUser(channelId: string, input: string): Promi
|
||||
const plan = await apiFetch(`/channels/${channelId}/invitations/${username}/crypto-plan`, { headers: session.authHeaders('owner') })
|
||||
.then(r => r.json() as Promise<InvitationCryptoPlan>);
|
||||
const body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise<PublicKeyBody>);
|
||||
const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey);
|
||||
const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey, { name: resource.name, invitedBy: account?.username });
|
||||
const domainEnvelopes = await packageInvitationDomains(resource, session, plan);
|
||||
await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload, domain_envelopes: domainEnvelopes }) });
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function recoverAccountChannels(username: string, keys: InvitationK
|
||||
creatorAccount: info.creator_account ?? undefined, name: info.settings.name ?? undefined,
|
||||
link: session.link(self.location.origin), ...(grant.role === 'owner' ? { ownerLink: session.link(self.location.origin, 'owner') } : {}),
|
||||
endpointSecret: new Uint8Array(32), createdAt: info.created_at, modifiedAt: grant.created_at, expiresAt: info.expires_at,
|
||||
seats: info.settings.seats, maxDownloads: info.settings.file_max_downloads };
|
||||
maxMembers: info.settings.max_members, maxDownloads: info.settings.file_max_downloads };
|
||||
if (signal.aborted) throw new DOMException('账号已切换', 'AbortError');
|
||||
await importResources([resource]);
|
||||
} finally { session.dispose(); }
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, it } from 'vitest';
|
||||
import { exportResource, mergeVault, type VaultProfile } from './accountVault';
|
||||
import type { SavedResource } from '../database';
|
||||
const resource = (modifiedAt = 10): SavedResource => ({ id: 'AAAAAAAAAAAAAAAAAAAAAA', kind: 'channel', mode: 'enc', role: 'owner',
|
||||
link: 'https://drop.test/c/a#k=secret', endpointSecret: new Uint8Array(32).fill(7), deviceLabel: 'device-a', localScope: 'account:alice',
|
||||
createdAt: 1, modifiedAt, expiresAt: 100, seats: 3, name: `version-${modifiedAt}` });
|
||||
link: 'https://drop.test/c/a#k=secret', endpointSecret: new Uint8Array(32).fill(7), localScope: 'account:alice',
|
||||
createdAt: 1, modifiedAt, expiresAt: 100, maxMembers: 3, name: `version-${modifiedAt}` });
|
||||
const profile = (): VaultProfile => ({ v: 3, keys: { publicKey: {}, privateKey: {}, signing: { publicKey: {}, privateKey: {} } }, resources: [exportResource(resource(20))], deletes: [] });
|
||||
describe('account vault portability and merge', () => {
|
||||
it('never synchronizes device identity or local scope', () => {
|
||||
const exported = exportResource(resource());
|
||||
expect(exported).not.toHaveProperty('endpointSecret'); expect(exported).not.toHaveProperty('deviceLabel');
|
||||
expect(exported).not.toHaveProperty('endpointSecret');
|
||||
expect(exported).not.toHaveProperty('localScope'); expect(exported.link).toContain('secret');
|
||||
});
|
||||
it('a stale device does not overwrite a newer remote resource', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ export function exportResource(resource: SavedResource): VaultResource {
|
||||
domainKeys: resource.domainKeys,
|
||||
link: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
|
||||
modifiedAt: resource.modifiedAt, creatorAccount: resource.creatorAccount, name: resource.name,
|
||||
seats: resource.seats, ttlSeconds: resource.ttlSeconds,
|
||||
maxMembers: resource.maxMembers, ttlSeconds: resource.ttlSeconds,
|
||||
maxDownloads: resource.maxDownloads, maxVisitors: resource.maxVisitors, mine: resource.mine, state: resource.state };
|
||||
}
|
||||
export function validateVault(value: unknown): VaultProfile {
|
||||
|
||||
@@ -3,11 +3,10 @@ import { canDecryptRecord, objectEventProjection, readDataKeys, verifyObjectDele
|
||||
import { getResource, cacheResource, type SavedResource } from '../database';
|
||||
import { channelCache } from './resourceMetadata';
|
||||
import { loadWorkspace } from './workspaceStructure';
|
||||
import type { SeatInfo } from '../generated/api';
|
||||
import type { FileItem, Item, Snapshot } from '../types';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import { blobs, readers } from './sessions';
|
||||
import { showRejected, deviceIdentity, deviceInfo, publishWorkspace } from './presentation';
|
||||
import { showRejected, publishWorkspace } from './presentation';
|
||||
import { type ChannelWire, toChannelInfo } from './wire';
|
||||
import { apiFetch, readJson } from './http';
|
||||
import { readResourceRecord } from './workspaceRecords';
|
||||
@@ -15,12 +14,11 @@ import { clearFileCatalog, fileCatalogs, visibleFiles } from './fileCatalog';
|
||||
|
||||
export function mapReceived(item: ReceivedItem): Item {
|
||||
const timestamp = item.meta?.ts ?? Date.now();
|
||||
const deviceId = item.meta?.dev ?? '未知设备';
|
||||
const state = item.complete ? { kind: 'complete' as const } : { kind: 'incomplete' as const };
|
||||
if (item.kind === 'file') {
|
||||
const name = item.meta?.name ?? '加密文件';
|
||||
return {
|
||||
id: item.itemId, kind: 'file', ts: timestamp, deviceId, state, name,
|
||||
id: item.itemId, kind: 'file', ts: timestamp, state, name,
|
||||
...(item.meta?.threadId ? { threadId: item.meta.threadId } : {}),
|
||||
...(item.meta?.folderId ? { folderId: item.meta.folderId } : {}),
|
||||
mime: item.meta?.mime ?? 'application/octet-stream', size: item.meta?.size ?? item.content.byteLength,
|
||||
@@ -28,7 +26,7 @@ export function mapReceived(item: ReceivedItem): Item {
|
||||
gated: item.gated,
|
||||
};
|
||||
}
|
||||
return { id: item.itemId, kind: 'text', ts: timestamp, deviceId, state,
|
||||
return { id: item.itemId, kind: 'text', ts: timestamp, state,
|
||||
...(item.meta?.threadId ? { threadId: item.meta.threadId } : {}),
|
||||
text: new TextDecoder().decode(item.content) };
|
||||
}
|
||||
@@ -70,11 +68,11 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
|
||||
const item = mapReceived(event.item);
|
||||
if (item.kind === 'file') visibleFiles.add(`${resource.id}:${item.id}`);
|
||||
if (event.item.snapshot) {
|
||||
let snapshot: Snapshot = { text: '', updatedBy: item.deviceId, updatedAt: item.ts };
|
||||
let snapshot: Snapshot = { text: '', updatedBy: '', updatedAt: item.ts };
|
||||
if (item.kind === 'text') {
|
||||
try {
|
||||
const value = JSON.parse(item.text) as Partial<Snapshot>;
|
||||
if (typeof value.text === 'string') snapshot = { text: value.text, updatedBy: value.updatedBy ?? item.deviceId, updatedAt: value.updatedAt ?? item.ts };
|
||||
if (typeof value.text === 'string') snapshot = { text: value.text, updatedBy: value.updatedBy ?? '', updatedAt: value.updatedAt ?? item.ts };
|
||||
else snapshot.text = item.text;
|
||||
} catch { snapshot.text = item.text; }
|
||||
}
|
||||
@@ -93,7 +91,7 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
|
||||
if (catalog) { publishWorkspace(resource.id, { t: 'file-catalog', item: catalog }); break; }
|
||||
const local = (await getResource(resource.id))?.localItems?.find((item) => item.id === event.itemId);
|
||||
const placeholder: FileItem = local?.kind === 'file' ? local : {
|
||||
id: event.itemId, kind: 'file', ts: event.createdAt, deviceId: '未知设备', state: { kind: 'incomplete' },
|
||||
id: event.itemId, kind: 'file', ts: event.createdAt, state: { kind: 'incomplete' },
|
||||
name: '受限下载内容', mime: 'application/octet-stream', size: 0, remaining: null, preview: 'none',
|
||||
gated: true, metadataHidden: true,
|
||||
};
|
||||
@@ -128,22 +126,6 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS
|
||||
Object.assign(resource, await cacheResource(resource, channelCache(info)));
|
||||
publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) });
|
||||
await loadWorkspace(resource.id);
|
||||
const ownId = toBase64Url(session.endpointHash);
|
||||
const ownName = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
|
||||
let devices = [deviceInfo(ownId, Date.now(), ownName, ownId)];
|
||||
if (session.can('owner')) {
|
||||
const seats = await readJson<SeatInfo[]>(
|
||||
`/channels/${resource.id}/seats`, session.authHeaders('owner'));
|
||||
devices = await Promise.all(seats.map(async (seat) => {
|
||||
let name: string | undefined;
|
||||
if (seat.label) {
|
||||
try { name = await session.openLabel(seat.label, seat.endpoint_hash); }
|
||||
catch { name = '设备名校验失败'; }
|
||||
}
|
||||
return deviceInfo(seat.endpoint_hash, seat.joined_at, name ?? (seat.endpoint_hash === ownId ? ownName : undefined), ownId);
|
||||
}));
|
||||
}
|
||||
publishWorkspace(resource.id, { t: 'devices', devices });
|
||||
}
|
||||
|
||||
export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise<number> {
|
||||
|
||||
@@ -8,16 +8,30 @@ interface ChannelGrant {
|
||||
mode: 'enc' | 'raw';
|
||||
role: 'owner' | 'member';
|
||||
keys: ParsedChannelLink['keys'];
|
||||
/** 仅供被邀请方在收件箱显示,在加密 payload 内,服务端不可见 */
|
||||
name?: string;
|
||||
invitedBy?: string;
|
||||
}
|
||||
|
||||
export async function sealChannelGrant(link: string, recipient: string, publicKey: JsonWebKey): Promise<string> {
|
||||
export interface InvitationMeta { name?: string; invitedBy?: string }
|
||||
|
||||
export async function sealChannelGrant(link: string, recipient: string, publicKey: JsonWebKey, meta: InvitationMeta = {}): Promise<string> {
|
||||
const parsed = parseChannelLink(link);
|
||||
if (!parsed.ok) throw new Error('空间凭据格式无效');
|
||||
const grant: ChannelGrant = { v: 1, channelId: parsed.link.id, mode: parsed.link.mode,
|
||||
role: parsed.link.isOwner ? 'owner' : 'member', keys: parsed.link.keys };
|
||||
role: parsed.link.isOwner ? 'owner' : 'member', keys: parsed.link.keys,
|
||||
...(meta.name ? { name: meta.name } : {}), ...(meta.invitedBy ? { invitedBy: meta.invitedBy } : {}) };
|
||||
return encryptInvitation(JSON.stringify(grant), recipient, grant.channelId, publicKey);
|
||||
}
|
||||
|
||||
/** 只读取显示用的元信息;解不开或没有这些字段时返回空,调用方回退到空间 ID */
|
||||
export async function peekChannelGrant(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey): Promise<InvitationMeta> {
|
||||
try {
|
||||
const grant = JSON.parse(await decryptInvitation(payload, recipient, channelId, privateKey)) as Partial<ChannelGrant>;
|
||||
return { ...(typeof grant.name === 'string' ? { name: grant.name } : {}), ...(typeof grant.invitedBy === 'string' ? { invitedBy: grant.invitedBy } : {}) };
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
export async function openChannelGrant(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey,
|
||||
role: 'owner' | 'member', origin: string): Promise<ParsedChannelLink> {
|
||||
const plain = await decryptInvitation(payload, recipient, channelId, privateKey);
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function getChannelBlob(resourceId: string, itemId: string, version
|
||||
verifyFileCatalog(fileCatalogs.get(`${resourceId}:${itemId}`), result.meta, result.blob.size);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof IntegrityError) publishWorkspace(resourceId, { t: 'item', item: { id: itemId, kind: 'text', ts: Date.now(), deviceId: '', text: '', state: { kind: 'tampered' } } });
|
||||
if (error instanceof IntegrityError) publishWorkspace(resourceId, { t: 'item', item: { id: itemId, kind: 'text', ts: Date.now(), text: '', state: { kind: 'tampered' } } });
|
||||
throw error;
|
||||
}
|
||||
const fileBlob = result.meta?.kind === 'file' && typeof File !== 'undefined'
|
||||
@@ -58,7 +58,7 @@ export async function getChannelBlob(resourceId: string, itemId: string, version
|
||||
blobs.set(`${resourceId}:${itemId}`, fileBlob);
|
||||
if (result.meta?.kind === 'file') {
|
||||
const item: FileItem = {
|
||||
id: itemId, kind: 'file', ts: result.meta.ts, deviceId: result.meta.dev ?? '未知设备', state: { kind: 'complete' },
|
||||
id: itemId, kind: 'file', ts: result.meta.ts, state: { kind: 'complete' },
|
||||
...(result.meta.threadId ? { threadId: result.meta.threadId } : {}),
|
||||
...(result.meta.folderId ? { folderId: result.meta.folderId } : {}),
|
||||
name: result.meta.name ?? '加密文件', mime: result.meta.mime ?? result.blob.type, size: fileBlob.size,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChannelSession, toBase64Url } from '@webdrop/core';
|
||||
import { ChannelSession } from '@webdrop/core';
|
||||
import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../database';
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
|
||||
@@ -7,14 +7,12 @@ import { apiFetch, readJson, getServerInfo } from './http';
|
||||
import { channelCache, isOfflineFailure } from './resourceMetadata';
|
||||
import { accountToken } from './accountSession';
|
||||
import { attachDataKeys, initializeChannelKeys } from './channelDataKeys';
|
||||
import type { ChannelSettingsPatch as SettingsRequest, SeatJoined, MemberBody } from '../generated/api';
|
||||
import type { ChannelSettingsPatch as SettingsRequest, JoinResult, MemberBody } from '../generated/api';
|
||||
import { getSession, sessions } from './sessions';
|
||||
import { deviceIdentity } from './presentation';
|
||||
import { makeLink } from './links';
|
||||
import { toChannelInfo, type ChannelWire } from './wire';
|
||||
import { stopChannel } from './channelTransport';
|
||||
import { publishWorkspace } from './presentation';
|
||||
import { channelMetadata } from './channelEvents';
|
||||
import { requireAccount, accountPublicKey } from './accountSession';
|
||||
import { sealChannelGrant } from './channelGrant';
|
||||
|
||||
@@ -32,7 +30,7 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
|
||||
settings: {
|
||||
...defaults,
|
||||
name: options.name?.trim() || undefined,
|
||||
seats: options.seats,
|
||||
max_members: options.maxMembers,
|
||||
ttl_seconds: options.ttlSeconds,
|
||||
members_can_upload: options.membersCanUpload,
|
||||
file_max_downloads: options.fileMaxDownloads,
|
||||
@@ -53,13 +51,9 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
|
||||
name: info.settings.name ?? undefined,
|
||||
link: session.link(self.location.origin), ownerLink: session.link(self.location.origin, 'owner'),
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
|
||||
seats: info.settings.seats,
|
||||
maxMembers: info.settings.max_members,
|
||||
maxDownloads: info.settings.file_max_downloads, localItems: [],
|
||||
};
|
||||
const ownerLabel = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
|
||||
await apiFetch(`/channels/${session.id}/seats/me`, {
|
||||
method: 'PUT', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, body: ownerLabel,
|
||||
});
|
||||
await saveResource(resource);
|
||||
sessions.set(session.id, session);
|
||||
attachDataKeys(session, resource);
|
||||
@@ -80,10 +74,10 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s
|
||||
try {
|
||||
const info = await readJson<ChannelWire>(`/channels/${id}`, session.authHeaders('read'));
|
||||
session.verifyChannelInfo(info);
|
||||
const joined = await apiFetch(`/channels/${id}/seats`, {
|
||||
const joined = await apiFetch(`/channels/${id}/join`, {
|
||||
method: 'POST', headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'read'), 'Content-Type': 'application/json' }, body: JSON.stringify({ password: input.password }),
|
||||
}).then((response) => response.json() as Promise<SeatJoined>);
|
||||
info.seats_used = joined.seats_used;
|
||||
}).then((response) => response.json() as Promise<JoinResult>);
|
||||
info.members_used = joined.members_used;
|
||||
const resource: SavedResource = {
|
||||
...channelCache(info),
|
||||
id, kind: 'channel', mode: session.mode, role: session.role,
|
||||
@@ -91,13 +85,9 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s
|
||||
name: info.settings.name ?? undefined,
|
||||
link: session.link(self.location.origin), ownerLink: session.can('owner') ? session.link(self.location.origin, 'owner') : undefined,
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
|
||||
seats: info.settings.seats,
|
||||
maxMembers: info.settings.max_members,
|
||||
maxDownloads: info.settings.file_max_downloads, localItems: existing?.localItems ?? [],
|
||||
};
|
||||
const label = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
|
||||
await apiFetch(`/channels/${id}/seats/me`, {
|
||||
method: 'PUT', headers: { ...session.authHeaders('read'), 'Content-Type': 'application/octet-stream' }, body: label,
|
||||
});
|
||||
await saveResource(resource);
|
||||
sessions.set(id, session);
|
||||
return toChannelInfo(info, resource);
|
||||
@@ -114,7 +104,7 @@ export async function previewChannel(link: ParsedChannelLink): Promise<ChannelIn
|
||||
session.verifyChannelInfo(info);
|
||||
return toChannelInfo(info, { id: link.id, kind: 'channel', mode: session.mode, role: session.role,
|
||||
endpointSecret: session.endpointSecret, link: session.link(self.location.origin), createdAt: info.created_at,
|
||||
expiresAt: info.expires_at, seats: info.settings.seats });
|
||||
expiresAt: info.expires_at, maxMembers: info.settings.max_members });
|
||||
} finally { session.dispose(); }
|
||||
}
|
||||
|
||||
@@ -146,7 +136,7 @@ export async function listChannels(): Promise<ChannelInfo[]> {
|
||||
if (!isOfflineFailure(error)) throw error;
|
||||
if (resource.channelMetadata) return { ...toChannelInfo(resource.channelMetadata, resource), dataSource: 'cache' };
|
||||
return {
|
||||
id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, seats: resource.seats, maxDownloads: resource.maxDownloads ?? undefined,
|
||||
id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, maxMembers: resource.maxMembers, maxDownloads: resource.maxDownloads ?? undefined,
|
||||
creatorAccount: resource.creatorAccount,
|
||||
memberLink: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
|
||||
dataSource: 'cache',
|
||||
@@ -161,7 +151,7 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
|
||||
if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置');
|
||||
const body: SettingsRequest = {};
|
||||
if (patch.name !== undefined) body.name = patch.name.trim();
|
||||
if (patch.seats !== undefined) body.seats = patch.seats;
|
||||
if (patch.maxMembers !== undefined) body.max_members = patch.maxMembers;
|
||||
if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload;
|
||||
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
|
||||
if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads;
|
||||
@@ -177,36 +167,6 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
|
||||
publishWorkspace(resourceId, { t: 'channel', info: toChannelInfo(info, updated) });
|
||||
}
|
||||
|
||||
export async function renameDevice(resourceId: string, endpointHash: string, name: string): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
const ownEndpointHash = toBase64Url(session.endpointHash);
|
||||
const isSelf = endpointHash === ownEndpointHash;
|
||||
if (!isSelf && !session.can('owner')) throw new Error('只有所有者可以重命名其他设备');
|
||||
const normalized = name.trim();
|
||||
if (!normalized) throw new Error('设备名称不能为空');
|
||||
const label = await session.sealLabel(normalized, { endpointHash });
|
||||
const path = isSelf
|
||||
? `/channels/${resourceId}/seats/me`
|
||||
: `/channels/${resourceId}/seats/${endpointHash}/label`;
|
||||
const role = !isSelf || session.can('owner') ? 'owner' : 'read';
|
||||
await apiFetch(path, {
|
||||
method: 'PUT', headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream' }, body: label,
|
||||
});
|
||||
if (isSelf) {
|
||||
resource.deviceLabel = normalized;
|
||||
await saveResource(resource);
|
||||
}
|
||||
await channelMetadata(resource, session);
|
||||
}
|
||||
|
||||
export async function releaseDevice(resourceId: string, endpointHash: string): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (!session.can('owner')) throw new Error('只有所有者可以移除设备');
|
||||
if (endpointHash === toBase64Url(session.endpointHash)) throw new Error('不能移除当前设备');
|
||||
await apiFetch(`/channels/${resourceId}/seats/${endpointHash}`, { method: 'DELETE', headers: session.authHeaders('owner') });
|
||||
await channelMetadata(resource, session);
|
||||
}
|
||||
|
||||
export async function forgetChannel(id: string): Promise<void> {
|
||||
stopChannel(id);
|
||||
sessions.get(id)?.dispose();
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { FileItem, FileUploadOptions, Item } from '../types';
|
||||
|
||||
import { blobs, getSession } from './sessions';
|
||||
import { deviceIdentity } from './presentation';
|
||||
import { accountDisplay } from './accountSession';
|
||||
|
||||
|
||||
|
||||
@@ -18,18 +19,15 @@ import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPla
|
||||
import { publishWorkspace } from './presentation';
|
||||
|
||||
export async function sendText(resourceId: string, text: string, threadId?: string,
|
||||
event?: { kind: 'chat.create' | 'chat.edit'; targetItemId?: string }): Promise<{ itemId: string; uploaded: boolean }> {
|
||||
event?: { kind: 'chat.create' | 'chat.edit'; targetItemId?: string }, localId?: string): Promise<{ itemId: string; uploaded: boolean }> {
|
||||
if (!threadId) throw new Error('请先选择频道');
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限');
|
||||
const identity = await deviceIdentity(session.endpointSecret);
|
||||
const device = { ...identity, name: resource.deviceLabel ?? identity.name };
|
||||
session.deviceName = device.name;
|
||||
const ts = Date.now();
|
||||
const author = await senderSnapshot();
|
||||
const prepared = await prepareObject(resource, session, threadId, 'chat');
|
||||
const plan = session.encryptText(text, { dev: device.name, ts, threadId, itemId: prepared.itemIdBytes });
|
||||
const item: Item = { id: plan.itemId, kind: 'text', ts, deviceId: device.name, ...(author ? { author } : {}), state: { kind: 'receiving', progress: 0 }, text, ...(threadId ? { threadId } : {}) };
|
||||
const plan = session.encryptText(text, { ts, threadId, itemId: prepared.itemIdBytes });
|
||||
const item: Item = { id: plan.itemId, kind: 'text', ts, ...(author ? { author } : {}), state: { kind: 'receiving', progress: 0 }, text, ...(threadId ? { threadId } : {}), ...(localId ? { localId } : {}) };
|
||||
blobs.set(`${resourceId}:${item.id}`, new Blob([text], { type: 'text/plain' }));
|
||||
publishWorkspace(resourceId, { t: 'item', item });
|
||||
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch,
|
||||
@@ -41,17 +39,16 @@ export async function sendText(resourceId: string, text: string, threadId?: stri
|
||||
return { itemId: plan.itemId, uploaded };
|
||||
}
|
||||
|
||||
export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, threadId?: string, folderId?: string): Promise<void> {
|
||||
export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, threadId?: string, folderId?: string, localIds?: string[]): Promise<void> {
|
||||
if (!threadId) throw new Error('请先选择频道');
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
const options = suppliedOptions ?? { directPreview: true, maxDownloads: resource.maxDownloads ?? 0 };
|
||||
if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限');
|
||||
const identity = await deviceIdentity(session.endpointSecret);
|
||||
const device = { ...identity, name: resource.deviceLabel ?? identity.name };
|
||||
session.deviceName = device.name;
|
||||
const device = await deviceIdentity(session.endpointSecret);
|
||||
const maxFrameData = device.kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
|
||||
const author = await senderSnapshot();
|
||||
for (const file of files) {
|
||||
for (const [index, file] of files.entries()) {
|
||||
const localId = localIds?.[index];
|
||||
const ts = Date.now();
|
||||
let item: FileItem | undefined;
|
||||
try {
|
||||
@@ -59,12 +56,12 @@ export async function sendFiles(resourceId: string, files: File[], suppliedOptio
|
||||
const plan = session.encryptFile(file, {}, {
|
||||
itemId: prepared.itemIdBytes,
|
||||
...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }),
|
||||
dev: device.name, ts, maxFrameData, threadId, folderId,
|
||||
ts, maxFrameData, threadId, folderId,
|
||||
});
|
||||
item = {
|
||||
id: plan.itemId, kind: 'file', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
|
||||
id: plan.itemId, kind: 'file', ts, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
|
||||
...(author ? { author } : {}),
|
||||
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}),
|
||||
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), ...(localId ? { localId } : {}),
|
||||
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
|
||||
remaining: plan.gated
|
||||
? (options.directPreview ? resource.maxDownloads ?? 0 : options.maxDownloads) || null
|
||||
@@ -127,11 +124,11 @@ export async function sendFiles(resourceId: string, files: File[], suppliedOptio
|
||||
|
||||
export async function updateSnapshot(resourceId: string, text: string): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
const name = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
|
||||
const name = accountDisplay()?.username ?? '';
|
||||
const { structure } = await import('./workspaceStructure').then(module => module.loadWorkspace(resourceId));
|
||||
if (!structure.defaultChannelId) throw new Error('请先创建默认频道');
|
||||
const prepared = await prepareObject(resource, session, structure.defaultChannelId, 'chat');
|
||||
const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, dev: name, itemId: prepared.itemIdBytes });
|
||||
const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, itemId: prepared.itemIdBytes });
|
||||
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'chat.snapshot' });
|
||||
await flushOutbox(resource, session);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,6 @@ export async function queueFileCatalog(resource: SavedResource, session: Channel
|
||||
const prepared = await prepareObject(resource, session, item.threadId, 'files');
|
||||
const control: ResourceControl = { op: 'file.published', item: toPublishedFile(item) };
|
||||
const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: WORKSPACE_APP, v: 1, ...control })),
|
||||
{ kind: 'other', threadId: item.threadId, dev: item.deviceId }, { ts: item.ts, control: true, itemId: prepared.itemIdBytes });
|
||||
{ kind: 'other', threadId: item.threadId }, { ts: item.ts, control: true, itemId: prepared.itemIdBytes });
|
||||
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'file.catalog' });
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { flushOutbox } from './transfer';
|
||||
import { socketStream, sseStream, delay, type StreamConsumer } from './transports';
|
||||
import { errorMessage } from '../errors';
|
||||
import { saveResource } from '../database';
|
||||
import { deviceIdentity } from './presentation';
|
||||
|
||||
export async function connectChannel(id: string): Promise<void> {
|
||||
active.get(id)?.abort();
|
||||
@@ -21,11 +20,9 @@ export async function connectChannel(id: string): Promise<void> {
|
||||
if (signal.aborted) return;
|
||||
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
|
||||
// Device sessions can be revoked independently of account membership; rejoin idempotently.
|
||||
await apiFetch(`/channels/${id}/seats`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal });
|
||||
await apiFetch(`/channels/${id}/join`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal });
|
||||
if (resource.needsJoin) {
|
||||
if (session.can('owner')) await channelMetadata(resource, session);
|
||||
const label = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
|
||||
await apiFetch(`/channels/${id}/seats/me`, { method: 'PUT', headers: session.authHeaders('read'), body: label, signal });
|
||||
resource.needsJoin = false;
|
||||
await saveResource(resource);
|
||||
}
|
||||
@@ -35,7 +32,7 @@ export async function connectChannel(id: string): Promise<void> {
|
||||
async control(value) {
|
||||
if (value.t === 'settings') {
|
||||
await Promise.all([channelMetadata(resource, session), loadWorkspace(id)]);
|
||||
} else if (value.t === 'seats') await channelMetadata(resource, session);
|
||||
} else if (value.t === 'members') await channelMetadata(resource, session);
|
||||
},
|
||||
online() { if (!signal.aborted) publishWorkspace(id, { t: 'connection', state: 'online' }); },
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { PublishedFile } from '../protocol/workspace';
|
||||
export function toPublishedFile(item: FileItem): PublishedFile {
|
||||
if (!item.threadId) throw new Error('请先选择频道');
|
||||
return { id: item.id, kind: 'file', threadId: item.threadId, folderId: item.folderId,
|
||||
name: item.name, mime: item.mime, size: item.size, ts: item.ts, deviceId: item.deviceId,
|
||||
name: item.name, mime: item.mime, size: item.size, ts: item.ts,
|
||||
gated: item.gated ?? false };
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ describe('API contract preflight', () => {
|
||||
});
|
||||
|
||||
it('blocks API requests when the server has a newer breaking major', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '7.0.0' }), { status: 200 }));
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '8.0.0' }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
setApiBase('http://api.test/api/v1');
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('API contract preflight', () => {
|
||||
|
||||
it('checks the contract once and then sends the API request', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '6.0.0' }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '7.0.0' }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ItemEvent } from '@webdrop/core';
|
||||
import type { Device, FileItem, ShareEvent, WorkspaceEvent } from '../types';
|
||||
import type { FileItem, ShareEvent, WorkspaceEvent } from '../types';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import { publish } from './port';
|
||||
export type TerminalKind = 'desktop' | 'phone' | 'tablet';
|
||||
|
||||
interface ClientHints {
|
||||
mobile?: boolean;
|
||||
model?: string;
|
||||
@@ -28,7 +30,7 @@ function version(value: string | undefined): string | undefined {
|
||||
return value?.split('.').slice(0, 2).join('.') || undefined;
|
||||
}
|
||||
|
||||
export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name: string; kind: Device['kind'] }> {
|
||||
export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name: string; kind: TerminalKind }> {
|
||||
const ua = navigator.userAgent;
|
||||
const hints = await readClientHints();
|
||||
const android = /Android/i.test(ua) || hints.platform === 'Android';
|
||||
@@ -36,7 +38,7 @@ export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name
|
||||
const ipad = /iPad/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const tablet = ipad || (android && (hints.mobile === false || (!hints.mobile && !/Mobile/i.test(ua))));
|
||||
const mobile = !tablet && (android || iphone || /Mobile/i.test(ua));
|
||||
const kind: Device['kind'] = tablet ? 'tablet' : mobile ? 'phone' : 'desktop';
|
||||
const kind: TerminalKind = tablet ? 'tablet' : mobile ? 'phone' : 'desktop';
|
||||
const iosVersion = ua.match(/(?:iPhone )?OS ([\d_]+)/i)?.[1]?.replaceAll('_', '.');
|
||||
const windowsVersion = version(hints.platformVersion);
|
||||
const macVersion = ua.match(/Mac OS X ([\d_]+)/i)?.[1]?.replaceAll('_', '.');
|
||||
@@ -66,24 +68,16 @@ export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name
|
||||
return { name: `${identity} · ${suffix}`, kind };
|
||||
}
|
||||
|
||||
export function deviceInfo(id: string, joinedAt: number, label: string | undefined, selfId: string): Device {
|
||||
const name = label ?? '未知设备';
|
||||
const kind: Device['kind'] = /iPhone|iPod/i.test(name) ? 'phone'
|
||||
: /iPad|Galaxy Tab|Pixel Tablet|Android.*Tablet/i.test(name) ? 'tablet'
|
||||
: /Android|Pixel|Galaxy|OnePlus|Xiaomi|Redmi|OPPO|vivo|Huawei/i.test(name) ? 'phone'
|
||||
: 'desktop';
|
||||
return { id, name, kind, self: id === selfId, joinedAt };
|
||||
}
|
||||
export function showRejected(resourceId: string, event: Extract<ItemEvent, { type: 'rejected' }>, share = false): void {
|
||||
if (share) publish(resourceId, { t: 'integrity', message: '分享清单校验失败,内容已拒绝显示。' });
|
||||
else if (event.itemId) publish(resourceId, {
|
||||
t: 'item', item: { id: event.itemId, kind: 'text', ts: Date.now(), deviceId: '未知设备', state: { kind: 'tampered' }, text: '' },
|
||||
t: 'item', item: { id: event.itemId, kind: 'text', ts: Date.now(), state: { kind: 'tampered' }, text: '' },
|
||||
});
|
||||
else publish(resourceId, { t: 'error', message: '内容校验失败,已拒绝该记录。' });
|
||||
}
|
||||
|
||||
export function makeFileItem(id: string, name: string, mime: string, size: number, ts: number, state: FileItem['state'], remaining: number | null = null): FileItem {
|
||||
return { id, kind: 'file', ts, deviceId: '创建者', state, name, mime, size, remaining, preview: previewKindOf(mime, name) };
|
||||
return { id, kind: 'file', ts, state, name, mime, size, remaining, preview: previewKindOf(mime, name) };
|
||||
}
|
||||
export function publishWorkspace(resourceId: string, event: WorkspaceEvent): void {
|
||||
publish(resourceId, event);
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('resource and rendering boundaries', () => {
|
||||
expect(isOfflineFailure(new Error('bad_meta'))).toBe(false);
|
||||
});
|
||||
it('publishes only resource metadata, excluding UI state and download counters', () => {
|
||||
const item: FileItem = { id: 'a', threadId: 'channel', kind: 'file', name: 'secret.txt', mime: 'text/plain', size: 10, ts: 1, deviceId: 'device',
|
||||
const item: FileItem = { id: 'a', threadId: 'channel', kind: 'file', name: 'secret.txt', mime: 'text/plain', size: 10, ts: 1,
|
||||
gated: true, remaining: 5, preview: 'text', state: { kind: 'receiving', progress: 0.2 }, unseen: true, versions: [] };
|
||||
const wire = toPublishedFile(item);
|
||||
expect(wire.name).toBe('secret.txt');
|
||||
@@ -21,7 +21,7 @@ describe('resource and rendering boundaries', () => {
|
||||
});
|
||||
it('never synchronizes draft content or server structure snapshots inside the key vault', () => {
|
||||
const resource: SavedResource = { id: 'a', kind: 'share', mode: 'enc', role: 'owner', link: 'key', createdAt: 1, expiresAt: 2,
|
||||
endpointSecret: new Uint8Array(32), seats: 0, draftItems: [], manifestEntries: [],
|
||||
endpointSecret: new Uint8Array(32), maxMembers: 0, draftItems: [], manifestEntries: [],
|
||||
workspaceMetadata: { structure: { revision: 1, channels: [], groups: [], folders: [], locations: [] }, people: [] } };
|
||||
const exported = exportResource(resource);
|
||||
for (const field of ['draftItems', 'manifestEntries', 'workspaceMetadata', 'endpointSecret']) expect(exported).not.toHaveProperty(field);
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { SavedResource } from '../database';
|
||||
/** Server-owned fields only: preserve local credentials, drafts, and user edit timestamps. */
|
||||
export function channelCache(raw: ChannelInfo): Partial<SavedResource> {
|
||||
return { name: raw.settings.name ?? undefined, creatorAccount: raw.creator_account ?? undefined,
|
||||
createdAt: raw.created_at, expiresAt: raw.expires_at, seats: raw.settings.seats,
|
||||
createdAt: raw.created_at, expiresAt: raw.expires_at, maxMembers: raw.settings.max_members,
|
||||
maxDownloads: raw.settings.file_max_downloads,
|
||||
ttlSeconds: raw.settings.ttl_seconds, channelMetadata: raw };
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ function newTextEntry(text: string): { item: TextItem; entry: Extract<ManifestEn
|
||||
const ts = Date.now();
|
||||
const localId = `draft-text-${crypto.randomUUID()}`;
|
||||
return {
|
||||
item: { id: localId, kind: 'text', ts, deviceId: '创建者', state: { kind: 'complete' }, text },
|
||||
item: { id: localId, kind: 'text', ts, state: { kind: 'complete' }, text },
|
||||
entry: { t: 'text', localId, text, ts },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ async function loadSealedManifest(resource: SavedResource, session: ChannelSessi
|
||||
const entry = manifest.items[index];
|
||||
if (!entry) throw new Error('invalid_manifest_item');
|
||||
if (entry.t === 'text' && typeof entry.text === 'string' && Number.isSafeInteger(entry.ts)) {
|
||||
const item: TextItem = { id: `manifest-text-${index}`, kind: 'text', ts: entry.ts, deviceId: '创建者', state: { kind: 'complete' }, text: entry.text };
|
||||
const item: TextItem = { id: `manifest-text-${index}`, kind: 'text', ts: entry.ts, state: { kind: 'complete' }, text: entry.text };
|
||||
publishShare(resource.id, { t: 'item', item });
|
||||
} else if (entry.t === 'file' && typeof entry.id === 'string' && typeof entry.size === 'number' && Number.isSafeInteger(entry.size)) {
|
||||
const detail = byId.get(entry.id);
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function createShare(settings: ShareSettings, captchaToken?: string
|
||||
link: toShareLink(session.link(self.location.origin)),
|
||||
ownerLink: toShareLink(session.link(self.location.origin, 'owner')),
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret),
|
||||
createdAt: raw.created_at, expiresAt: raw.expires_at, seats: 0,
|
||||
createdAt: raw.created_at, expiresAt: raw.expires_at, maxMembers: 0,
|
||||
state: 'draft', maxVisitors: raw.settings.max_visitors,
|
||||
ttlSeconds: raw.settings.ttl_seconds,
|
||||
maxDownloads: raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads,
|
||||
@@ -64,7 +64,7 @@ export async function joinShare(input: { link: { id: string; key: string } }): P
|
||||
id: input.link.id, kind: 'share', mode: 'enc', role: 'member', mine: false,
|
||||
link: toShareLink(makeLink(self.location.origin, input.link.id, { k: input.link.key })),
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: raw.created_at, expiresAt: raw.expires_at,
|
||||
seats: 0, state: raw.state,
|
||||
maxMembers: 0, state: raw.state,
|
||||
ttlSeconds: raw.settings.ttl_seconds,
|
||||
maxVisitors: raw.settings.max_visitors, maxDownloads: raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads,
|
||||
};
|
||||
|
||||
@@ -14,8 +14,8 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn
|
||||
role: raw.can_manage_structure ? 'owner' : 'member',
|
||||
canUpload: raw.can_upload,
|
||||
canManageStructure: raw.can_manage_structure,
|
||||
seats: raw.settings.seats,
|
||||
membersUsed: raw.seats_used,
|
||||
maxMembers: raw.settings.max_members,
|
||||
membersUsed: raw.members_used,
|
||||
membersCanUpload: raw.settings.members_can_upload,
|
||||
ttlSeconds: raw.settings.ttl_seconds,
|
||||
maxDownloads: raw.settings.file_max_downloads,
|
||||
|
||||
@@ -21,6 +21,6 @@ export function readResourceRecord(item: ReceivedItem): WorkspaceEvent | false |
|
||||
|| !Number.isSafeInteger(file.size) || file.size < 0 || typeof file.gated !== 'boolean')
|
||||
return { t: 'error', message: '文件目录记录格式无效,已拒绝应用。' };
|
||||
return { t: 'file-catalog', item: { id: file.id, kind: 'file', threadId: file.threadId, folderId: file.folderId,
|
||||
name: file.name, mime: file.mime, size: file.size, ts: item.meta.ts, deviceId: item.meta.dev ?? '未知设备',
|
||||
name: file.name, mime: file.mime, size: file.size, ts: item.meta.ts,
|
||||
gated: file.gated, remaining: null, preview: previewKindOf(file.mime, file.name), state: { kind: 'complete' } } };
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
|
||||
import type { AccountInvitation } from '@/core-bridge/account';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './ChannelSettingsSheet.module.css';
|
||||
|
||||
export function ChannelInvitations({ owner }: { owner: boolean }) {
|
||||
const channelId = useWorkspace((s) => s.channel?.id);
|
||||
const invite = useWorkspace((s) => s.inviteUser);
|
||||
const list = useWorkspace((s) => s.listInvitations);
|
||||
const revoke = useWorkspace((s) => s.revokeInvitation);
|
||||
const [members, setMembers] = useState<ChannelMember[]>([]);
|
||||
const [invitations, setInvitations] = useState<AccountInvitation[]>([]);
|
||||
const [username, setUsername] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function refresh() {
|
||||
if (channelId) setMembers(await channelAccessApi.members(channelId));
|
||||
if (owner) setInvitations(await list());
|
||||
}
|
||||
useEffect(() => { void refresh().catch(() => setError('成员清单读取失败')); }, [channelId, owner, list]);
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault(); setBusy(true); setError('');
|
||||
try { await invite(username); setUsername(''); await refresh(); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '邀请失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function remove(username: string) {
|
||||
setBusy(true); setError('');
|
||||
try { await revoke(username); await refresh(); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '移除失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <section className={styles.form} aria-label="空间成员与邀请"><strong>空间成员</strong>
|
||||
{members.map((member) => <div className={styles.member} key={member.username}>
|
||||
<span>{member.username} · {member.role === 'owner' ? '空间主' : '成员'}</span>
|
||||
{owner && member.role !== 'owner' ? <Button variant="ghost" disabled={busy} onClick={() => { void remove(member.username); }}>移除成员</Button> : null}
|
||||
</div>)}
|
||||
<p className={styles.note}>未登录访客以独立设备身份加入,可在设备面板移除。</p>
|
||||
{owner ? <><strong>邀请用户</strong><form className={styles.member} onSubmit={(event) => { void submit(event); }}>
|
||||
<input aria-label="被邀请用户的用户名" maxLength={32} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="已注册用户的用户名" required />
|
||||
<Button type="submit" disabled={busy || !username.trim()}>发送邀请</Button>
|
||||
</form>
|
||||
{invitations.map((row) => <div className={styles.member} key={row.username}><span>{row.username} · 已邀请</span><Button variant="ghost" disabled={busy} onClick={() => { void remove(row.username); }}>撤销邀请</Button></div>)}
|
||||
<p className={styles.note}>移除会取消加入授权并断开该账号的设备。已读取的内容无法收回。</p></> : null}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import styles from './ChannelSettingsSheet.module.css';
|
||||
import { ChannelInvitations } from './ChannelInvitations';
|
||||
|
||||
import { useServerSettings } from '@/store/server';
|
||||
import type { ServerInfoBody } from '@/core-bridge/generated/api';
|
||||
@@ -25,7 +24,7 @@ export function ChannelSettingsSheet(props: Props) {
|
||||
function SettingsForm({ channel, onClose, onSave, info }: Props & { info: ServerInfoBody }) {
|
||||
const owner = channel.role === 'owner';
|
||||
const [name, setName] = useState(channel.name ?? '');
|
||||
const [seats, setSeats] = useState(channel.seats);
|
||||
const [maxMembers, setMaxMembers] = useState(channel.maxMembers);
|
||||
const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload!);
|
||||
const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds!);
|
||||
const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads!);
|
||||
@@ -43,7 +42,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
setSaving(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
await onSave({ name: name.trim(), seats, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
|
||||
await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
|
||||
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
|
||||
onClose();
|
||||
} catch {
|
||||
@@ -58,13 +57,13 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
<div className={styles.form}>
|
||||
<label className={styles.field}>
|
||||
<span>空间名称</span>
|
||||
<input type="text" maxLength={48} value={name} disabled={!owner} placeholder="例如:工作设备" onChange={(event) => setName(event.target.value)} />
|
||||
<input type="text" maxLength={48} value={name} disabled={!owner} placeholder="例如:项目组" onChange={(event) => setName(event.target.value)} />
|
||||
<span className={styles.note}>空间名称和设置对加入者及服务端可见。</span>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>成员人数上限</span>
|
||||
<input type="number" min={1} max={info.limits.max_seats} step={1} value={seats} disabled={!owner} onChange={(event) => setSeats(Number(event.target.value))} />
|
||||
<input type="number" min={1} max={info.limits.max_members} step={1} value={maxMembers} disabled={!owner} onChange={(event) => setMaxMembers(Number(event.target.value))} />
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
@@ -97,11 +96,11 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
<span className={styles.note}>空间主及受邀用户无需加入密码。修改规则不移除已加入成员。</span>
|
||||
</label>
|
||||
{owner && channel.passwordRequired ? <label className={styles.check}><input type="checkbox" checked={clearPassword} onChange={(event) => setClearPassword(event.target.checked)} /><span>移除空间加入密码</span></label> : null}
|
||||
<ChannelInvitations owner={owner} />
|
||||
<p className={styles.note}>成员与邀请请在右侧栏的「成员」页管理。</p>
|
||||
{owner ? (
|
||||
<>
|
||||
{failed ? <p className={styles.error} role="alert">保存失败,请检查名称和设置后重试。</p> : null}
|
||||
<Button variant="primary" disabled={saving || seats < 1 || seats > info.limits.max_seats || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8)} onClick={() => void save()}>{saving ? '保存中…' : '保存设置'}</Button>
|
||||
<Button variant="primary" disabled={saving || maxMembers < 1 || maxMembers > info.limits.max_members || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8)} onClick={() => void save()}>{saving ? '保存中…' : '保存设置'}</Button>
|
||||
</>
|
||||
) : <p className={styles.note}>只有所有者可以修改空间设置。</p>}
|
||||
</div>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { channelPath, navigate } from '@/app/navigation';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import styles from './ThreadSidebar.module.css';
|
||||
|
||||
export function ChannelSwitcher({ id, onCreate }: { id?: string; onCreate: () => void }) {
|
||||
const channels = useChannels((s) => s.list);
|
||||
const username = useAccount((s) => s.username);
|
||||
return <section className={styles.panel} aria-label="我的空间导航">
|
||||
<div className={styles.heading}><strong>空间</strong><Button variant="ghost" icon="edit" label="新建空间" disabled={!username} onClick={onCreate} /></div>
|
||||
<nav className={styles.channels} aria-label="空间列表">{channels.map((channel) => <button key={channel.id} type="button"
|
||||
className={`${styles.thread} ${channel.id === id ? styles.selected : ''}`} aria-current={channel.id === id ? 'page' : undefined}
|
||||
onClick={() => navigate(channelPath(channel.id))}><Icon name={channel.mode === 'enc' ? 'lock' : 'file'} size={14} /><span>{channel.name || channel.id.slice(0, 8)}</span></button>)}</nav>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,50 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { isLocalId, useWorkspace } from '@/store/workspace';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { useDownload } from '@/features/item/download';
|
||||
import { iconOf } from '@/features/item/summary';
|
||||
import { senderLabel } from '@/lib/sender';
|
||||
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './ChatPane.module.css';
|
||||
|
||||
export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }) {
|
||||
interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }
|
||||
|
||||
export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props) {
|
||||
const item = useWorkspace((s) => s.byId[id]);
|
||||
const owner = useWorkspace((s) => s.channel?.role === 'owner');
|
||||
const ownDevice = useWorkspace((s) => s.devices.find((device) => device.self)?.name);
|
||||
const retry = useWorkspace((s) => s.retrySend);
|
||||
const username = useAccount((s) => s.username);
|
||||
const download = useDownload();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
if (!item) return null;
|
||||
const sender = item.author;
|
||||
const name = sender?.displayName || sender?.username || item.deviceId || '未知用户';
|
||||
const self = sender?.username !== undefined ? sender.username === username : Boolean(ownDevice && item.deviceId === ownDevice);
|
||||
const name = senderLabel(item);
|
||||
const self = sender?.username !== undefined && sender.username === username;
|
||||
const local = isLocalId(id);
|
||||
const sending = local && item.state.kind === 'receiving';
|
||||
const failed = local && item.state.kind === 'incomplete';
|
||||
const ready = item.state.kind === 'complete';
|
||||
const deleteLabel = item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息';
|
||||
async function save() {
|
||||
if (!item || downloading) return;
|
||||
setDownloading(true); try { await download(item); } finally { setDownloading(false); }
|
||||
}
|
||||
return <article className={`${styles.message} ${self ? styles.mine : ''} ${compact ? styles.compact : ''}`} title={compact ? `${name} · ${formatTime(item.ts)}` : undefined}
|
||||
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`}>
|
||||
{compact ? <span className={styles.avatarSpacer} aria-hidden="true" /> : sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
|
||||
: <span className={`${styles.avatar} ${styles.avatarFallback}`} role="img" aria-label={`${name} 的默认头像`}>{Array.from(name)[0] ?? '?'}</span>}
|
||||
<div className={styles.bubble}>
|
||||
{!compact ? <header><strong className={styles.senderName} title={sender ? `${sender.username} · ${item.deviceId}` : item.deviceId}>{name}</strong><time>{formatTime(item.ts)}</time>
|
||||
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
|
||||
</header> : null}
|
||||
{compact && (owner || self) ? <Button className={`${styles.deleteAction} ${styles.compactDelete}`} variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
|
||||
return <article className={`${styles.message} ${compact ? styles.compact : ''} ${sending ? styles.sending : ''}`}
|
||||
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`} aria-busy={sending}>
|
||||
{compact ? <time className={styles.gutterTime} dateTime={new Date(item.ts).toISOString()}>{formatTime(item.ts)}</time>
|
||||
: sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
|
||||
: <span className={`${styles.avatar} ${styles.avatarFallback}`} role="img" aria-label={`${name} 的默认头像`}>{Array.from(name)[0] ?? '?'}</span>}
|
||||
<div className={styles.body}>
|
||||
{!compact ? <header><strong className={styles.senderName} title={sender?.username}>{name}</strong><time>{formatTime(item.ts)}</time></header> : null}
|
||||
{item.state.kind === 'tampered' ? <p className={styles.integrity}>内容校验失败,已拒绝显示。</p> : item.kind === 'text' ? <p className={styles.messageText}>{item.text}</p>
|
||||
: <div className={styles.fileMessage}>
|
||||
<button type="button" className={styles.fileCard} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
|
||||
: <div className={styles.fileCard}>
|
||||
<button type="button" className={styles.fileOpen} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
|
||||
<span className={styles.fileIcon}><Icon name={iconOf(item)} size={28} /></span>
|
||||
<span className={styles.fileInfo}><strong>{item.name}</strong><span>{formatSize(item.size)} · {item.mime}</span></span>
|
||||
</button>
|
||||
<div className={styles.fileActions}><Button variant="ghost" icon="eye" disabled={!ready} onClick={() => onOpenFile(id)}>预览</Button>
|
||||
<Button variant="ghost" icon="download" disabled={!ready || downloading} onClick={() => { void save(); }}>{downloading ? '下载中…' : '下载'}</Button>
|
||||
</div>
|
||||
<Button variant="ghost" icon="eye" label="预览" disabled={!ready} onClick={() => onOpenFile(id)} />
|
||||
<Button variant="ghost" icon="download" label={downloading ? '下载中…' : '下载'} disabled={!ready || downloading} onClick={() => { void save(); }} />
|
||||
</div>}
|
||||
{item.state.kind === 'receiving' ? <div className={styles.delivery} role="status">{formatTransferProgress(item.state)}<progress max={1} value={item.state.progress} /></div>
|
||||
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
|
||||
{item.state.kind === 'receiving' && item.kind === 'file' ? <div className={styles.delivery} role="status">{formatTransferProgress(item.state)}<progress max={1} value={item.state.progress} /></div>
|
||||
: failed ? <span className={styles.failure} role="alert">发送失败。<button type="button" onClick={() => { void retry(id); }}>重试</button></span>
|
||||
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
|
||||
</div>
|
||||
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={deleteLabel} onClick={() => onDelete(id)} /> : null}
|
||||
</article>;
|
||||
}
|
||||
@@ -3,23 +3,28 @@
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
min-height: var(--bar-h);
|
||||
padding: 0 var(--space-3);
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.hash {
|
||||
color: var(--text-dim);
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.head span,
|
||||
.empty,
|
||||
.message time {
|
||||
.message time,
|
||||
.delivery {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -27,59 +32,72 @@
|
||||
.messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
padding: var(--space-4) 0 var(--space-2);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: var(--space-2) 0;
|
||||
margin: 0;
|
||||
padding: 0 var(--space-4);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Discord 式:所有消息同一条左边线,头像 + 名称/时间 + 正文占满宽度 */
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-3);
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr);
|
||||
column-gap: var(--space-3);
|
||||
padding: var(--space-1) var(--space-4);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.mine {
|
||||
flex-direction: row-reverse;
|
||||
.message:hover,
|
||||
.message:focus-within {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.compact {
|
||||
margin-top: calc(var(--space-2) * -1);
|
||||
margin-bottom: var(--space-1);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
border-radius: var(--radius-avatar);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarSpacer {
|
||||
width: 40px;
|
||||
height: 1px;
|
||||
flex: 0 0 40px;
|
||||
}
|
||||
|
||||
.avatarFallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
position: relative;
|
||||
/* 连发合并行:头像位置平时留空,悬停才显示时间 */
|
||||
.gutterTime {
|
||||
align-self: start;
|
||||
padding-top: 3px;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.message:hover .gutterTime,
|
||||
.message:focus-within .gutterTime {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.body {
|
||||
min-width: 0;
|
||||
max-width: min(82%, 760px);
|
||||
padding: 0 0 var(--space-1);
|
||||
}
|
||||
|
||||
.message header {
|
||||
@@ -93,22 +111,28 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message header time {
|
||||
margin-inline-end: auto;
|
||||
.messageText {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.deleteAction {
|
||||
width: 26px;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
min-height: 26px;
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: var(--space-4);
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0;
|
||||
background: var(--surface);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease-out;
|
||||
}
|
||||
|
||||
.message:hover .deleteAction,
|
||||
@@ -116,24 +140,11 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.compactDelete {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: calc(var(--space-2) * -1);
|
||||
}
|
||||
|
||||
.messageText {
|
||||
margin: var(--space-1) 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dateDivider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin: var(--space-4) 0 var(--space-3);
|
||||
margin: var(--space-4) var(--space-4) var(--space-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -147,17 +158,26 @@
|
||||
content: '';
|
||||
}
|
||||
|
||||
.fileMessage {
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.fileCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
margin-top: var(--space-1);
|
||||
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.fileOpen {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-3) 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
@@ -165,6 +185,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileOpen:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.fileIcon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -181,23 +205,19 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fileInfo > span,
|
||||
.delivery {
|
||||
.fileInfo > span {
|
||||
color: var(--text-dim);
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.fileActions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.delivery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.delivery progress {
|
||||
@@ -206,7 +226,23 @@
|
||||
accent-color: var(--busy);
|
||||
}
|
||||
|
||||
.failure {
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.failure button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.integrity {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -214,36 +250,56 @@
|
||||
.composer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
gap: var(--space-1);
|
||||
margin: var(--space-2) var(--space-4) var(--space-4);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.composer:focus-within {
|
||||
outline: 2px solid var(--line-strong);
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
max-height: 112px;
|
||||
padding: var(--space-2);
|
||||
resize: vertical;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
min-height: 36px;
|
||||
max-height: 160px;
|
||||
padding: var(--space-2) var(--space-1);
|
||||
resize: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 0 var(--space-3) var(--space-2);
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
@media (width <= 899px) {
|
||||
.pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
.message {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
column-gap: var(--space-2);
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.deleteAction {
|
||||
inset-inline-end: var(--space-2);
|
||||
}
|
||||
|
||||
.composer {
|
||||
margin: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.deleteAction {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -8,36 +8,43 @@ import styles from './ChatPane.module.css';
|
||||
|
||||
interface Props { threadName: string; ids: string[]; enabled: boolean; onSend: (text: string) => Promise<void>; onDelete: (id: string) => void; onOpenFile: (id: string) => void; onFiles?: (files: File[]) => void }
|
||||
|
||||
const MAX_INPUT_HEIGHT = 160;
|
||||
|
||||
export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFile, onFiles }: Props) {
|
||||
const [text, setText] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const messages = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const byId = useWorkspace((s) => s.byId);
|
||||
const chronological = ids.slice().reverse();
|
||||
|
||||
useEffect(() => { messages.current?.scrollTo({ top: messages.current.scrollHeight }); }, [chronological.length]);
|
||||
|
||||
async function submit() {
|
||||
function fit() {
|
||||
const el = input.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, MAX_INPUT_HEIGHT)}px`;
|
||||
}
|
||||
|
||||
// 乐观发送:不等后端,先清空输入框,消息立刻画出来;失败由消息行自己显示重试
|
||||
function submit() {
|
||||
const value = text.trim();
|
||||
if (!value || busy || !enabled) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try { await onSend(value); setText(''); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '发送失败'); }
|
||||
finally { setBusy(false); }
|
||||
if (!value || !enabled) return;
|
||||
setText('');
|
||||
if (input.current) input.current.style.height = 'auto';
|
||||
void onSend(value);
|
||||
}
|
||||
|
||||
function keyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void submit(); }
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); submit(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.pane} aria-label={`${threadName} 聊天`}>
|
||||
<div className={styles.head}><strong>{threadName}</strong><span>频道聊天</span></div>
|
||||
<div className={styles.head}><span className={styles.hash} aria-hidden="true">#</span><strong>{threadName}</strong></div>
|
||||
<div className={styles.messages} ref={messages} aria-live="polite">
|
||||
{chronological.length === 0 ? <p className={styles.empty}>在这里发送消息或文件,成员可以看到你的名称和头像。</p> : null}
|
||||
{chronological.length === 0 ? <p className={styles.empty}>这里是 #{threadName} 的开头。发送消息或文件,成员可以看到你的名称和头像。</p> : null}
|
||||
{(() => {
|
||||
let previous: Item | undefined;
|
||||
return chronological.map((id) => {
|
||||
@@ -46,7 +53,7 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil
|
||||
const dateKey = new Date(item.ts).toDateString();
|
||||
const previousDate = previous ? new Date(previous.ts).toDateString() : undefined;
|
||||
const showDate = dateKey !== previousDate;
|
||||
const sameSender = previous && (previous.author?.username ?? previous.deviceId) === (item.author?.username ?? item.deviceId);
|
||||
const sameSender = previous && previous.author?.username === item.author?.username;
|
||||
const grouped = Boolean(sameSender && !showDate && item.ts - previous!.ts < 5 * 60_000);
|
||||
previous = item;
|
||||
return <Fragment key={id}>
|
||||
@@ -56,13 +63,13 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
<form className={styles.composer} onSubmit={(event) => { event.preventDefault(); void submit(); }}>
|
||||
<form className={styles.composer} onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||
{onFiles ? <><input ref={fileInput} type="file" multiple hidden onChange={(event) => { if (event.target.files?.length) onFiles([...event.target.files]); event.target.value = ''; }} />
|
||||
<Button variant="ghost" icon="clip" label="上传文件到当前频道" disabled={!enabled} onClick={() => fileInput.current?.click()} /></> : null}
|
||||
<textarea value={text} disabled={!enabled || busy} rows={2} placeholder={enabled ? '输入消息,Enter 发送,Shift+Enter 换行' : '先创建或加入一个频道'} onChange={(event) => setText(event.target.value)} onKeyDown={keyDown} />
|
||||
<Button variant="primary" icon="send" label="发送消息" disabled={!enabled || busy || !text.trim()} onClick={() => void submit()} />
|
||||
<Button variant="ghost" icon="plus" label="上传文件到当前频道" disabled={!enabled} onClick={() => fileInput.current?.click()} /></> : null}
|
||||
<textarea ref={input} value={text} disabled={!enabled} rows={1} aria-label="消息内容" placeholder={enabled ? `给 #${threadName} 发消息` : '先创建或加入一个频道'}
|
||||
onChange={(event) => { setText(event.target.value); fit(); }} onKeyDown={keyDown} />
|
||||
<Button type="submit" variant="ghost" icon="send" label="发送消息" disabled={!enabled || !text.trim()} />
|
||||
</form>
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { formatAgo } from '@/lib/format';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { CopyButton } from '@/ui/CopyButton';
|
||||
import { QrCode } from '@/ui/QrCode';
|
||||
import styles from './DevicePanel.module.css';
|
||||
|
||||
export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
|
||||
const deviceCount = useWorkspace((s) => s.devices.length);
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const username = useAccount((s) => s.username);
|
||||
const [members, setMembers] = useState<ChannelMember[]>([]);
|
||||
const [membersError, setMembersError] = useState('');
|
||||
const [showOwner, setShowOwner] = useState(false);
|
||||
const [showQr, setShowQr] = useState(qrOpen);
|
||||
const membersUsed = channel?.membersUsed ?? '—';
|
||||
const accountAddress = channel ? `${location.origin}/c/${channel.id}` : '';
|
||||
const refreshMembers = useCallback(async () => {
|
||||
if (!channel?.id) return;
|
||||
setMembersError('');
|
||||
try { setMembers(await channelAccessApi.members(channel.id)); }
|
||||
catch { setMembersError('成员清单读取失败'); }
|
||||
}, [channel?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshMembers();
|
||||
}, [refreshMembers, deviceCount]);
|
||||
|
||||
return (
|
||||
<aside className={styles.panel}>
|
||||
<section>
|
||||
<div className={styles.memberHeading}>
|
||||
<h2 className={styles.h}>成员 <span className={styles.dim}>{membersUsed}/{channel?.seats} 人</span></h2>
|
||||
<Button variant="ghost" disabled={!channel} onClick={() => { void refreshMembers(); }}>刷新</Button>
|
||||
</div>
|
||||
<ul className={styles.list}>
|
||||
{members.map((member) => <li key={member.username} className={styles.member}>
|
||||
<span className={styles.name}>{member.username}</span>
|
||||
<span className={member.username === username ? styles.memberCurrent : styles.memberRole}>{member.username === username ? '你' : member.role === 'owner' ? '空间主' : '成员'}</span>
|
||||
<time className={styles.memberTime}>{formatAgo(member.joined_at)}</time>
|
||||
</li>)}
|
||||
</ul>
|
||||
{!members.length && !membersError ? <p className={styles.note}>暂无账号成员。</p> : null}
|
||||
{membersError ? <p className={styles.note} role="alert">{membersError}</p> : null}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={styles.h}>分享</h2>
|
||||
<div className={styles.link}>
|
||||
<div className={styles.label}>账号空间地址</div>
|
||||
<code className={styles.code}>{accountAddress}</code>
|
||||
<CopyButton text={accountAddress} />
|
||||
<p className={styles.note}>此地址不包含密钥。在空间设置中邀请账号后,成员登录即可自动领取密钥包。</p>
|
||||
</div>
|
||||
{channel?.joinPolicy === 'open' ? <>
|
||||
<div className={styles.link}>
|
||||
<div className={styles.label}>成员链接</div>
|
||||
<code className={styles.code}>{channel?.memberLink}</code>
|
||||
<div className={styles.row}>
|
||||
<CopyButton text={channel?.memberLink ?? ''} />
|
||||
<Button variant="ghost" icon="scan" aria-expanded={showQr} onClick={() => setShowQr((v) => !v)}>
|
||||
{showQr ? '收起二维码' : '二维码'}
|
||||
</Button>
|
||||
</div>
|
||||
{showQr && channel ? <QrCode text={channel.memberLink} label="成员链接二维码" /> : null}
|
||||
</div>
|
||||
{channel?.ownerLink ? (
|
||||
<div className={styles.link}>
|
||||
<div className={styles.label}>所有者链接 <span className={styles.warn}>含管理权限</span></div>
|
||||
<code className={styles.code}>{showOwner ? channel.ownerLink : '••••••••••••••••••••'}</code>
|
||||
<div className={styles.row}>
|
||||
<Button variant="ghost" icon="eye" onClick={() => setShowOwner((v) => !v)}>{showOwner ? '隐藏' : '显示'}</Button>
|
||||
<CopyButton text={channel.ownerLink} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<p className={styles.note}>
|
||||
持有链接并符合空间加入规则后,可读取空间历史内容。不隐藏 IP 与访问时间。
|
||||
</p>
|
||||
</> : null}
|
||||
</section>
|
||||
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { senderLabel } from '@/lib/sender';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
@@ -16,7 +17,7 @@ export function ExplorerHover({ id, point }: { id: string; point: { x: number; y
|
||||
const preview = encrypted && !item.gated && item.state.kind === 'complete' && (item.preview === 'image' || item.preview === 'text');
|
||||
return createPortal(<aside className={styles.hover} role="tooltip" aria-label="悬浮文件预览" style={{ left: position.x, top: position.y }}>
|
||||
<div className={styles.hoverTitle}><Icon name={iconOf(item)} size={20} /><strong>{item.name}</strong></div>
|
||||
<p>{formatSize(item.size)} · {item.mime}</p><p>{formatTime(item.ts)} · 上传者 {item.author?.displayName ?? item.author?.username ?? item.deviceId} · {item.deviceId}</p>
|
||||
<p>{formatSize(item.size)} · {item.mime}</p><p>{formatTime(item.ts)} · 上传者 {senderLabel(item)}</p>
|
||||
{preview ? <HoverContent id={id} image={item.preview === 'image'} /> : <p>{item.gated ? '受限文件:悬停不读取内容,点击预览后再解密。' : item.state.kind === 'tampered' ? '内容校验失败,已拒绝预览。' : '双击文件打开完整预览。'}</p>}
|
||||
</aside>, document.body);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import { useToast } from '@/store/toast';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import styles from './MembersPane.module.css';
|
||||
|
||||
export function InviteSheet({ onClose, onInvited }: { onClose: () => void; onInvited: () => void }) {
|
||||
const invite = useWorkspace((s) => s.inviteUser);
|
||||
const [username, setUsername] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const target = username.trim();
|
||||
if (!target || busy) return;
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
await invite(target);
|
||||
useToast.getState().push({ kind: 'ok', text: `已向 ${target} 发送邀请` });
|
||||
onInvited(); onClose();
|
||||
} catch (cause) {
|
||||
const message = errorMessage(cause);
|
||||
setError(message);
|
||||
useToast.getState().push({ kind: 'error', text: `邀请失败:${message}` });
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return <Sheet title="邀请成员" onClose={() => { if (!busy) onClose(); }} placement="top">
|
||||
<form className={styles.invite} onSubmit={(event) => { void submit(event); }}>
|
||||
<label>用户名
|
||||
<input autoFocus maxLength={32} value={username} disabled={busy} placeholder="已注册用户的用户名" onChange={(event) => setUsername(event.target.value)} />
|
||||
</label>
|
||||
<p className={styles.note}>对方登录后会收到通知,接受邀请后加入空间。能否读取加入前的历史,取决于各频道的历史开关。</p>
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
<div className={styles.inviteActions}>
|
||||
<Button variant="ghost" disabled={busy} onClick={onClose}>取消</Button>
|
||||
<Button type="submit" variant="primary" disabled={busy || !username.trim()}>{busy ? '发送中…' : '发送邀请'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||
import { useDownload } from '@/features/item/download';
|
||||
import { InlineContent, inlineKindOf } from '@/features/item/InlineContent';
|
||||
import { shortDevice, titleOf } from '@/features/item/summary';
|
||||
import { senderLabel } from '@/lib/sender';
|
||||
import { titleOf } from '@/features/item/summary';
|
||||
import { Tile } from '@/features/item/Tile';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
@@ -16,7 +17,6 @@ interface Props {
|
||||
/** 手机首屏:最新一项放大,拇指区一键取走 */
|
||||
export function LatestCard({ id, onOpen }: Props) {
|
||||
const item = useWorkspace((s) => s.byId[id]);
|
||||
const device = useWorkspace((s) => s.devices.find((d) => d.id === s.byId[id]?.deviceId)?.name);
|
||||
const download = useDownload();
|
||||
if (!item) return null;
|
||||
|
||||
@@ -32,7 +32,7 @@ export function LatestCard({ id, onOpen }: Props) {
|
||||
<span className={styles.text}>
|
||||
{inline === 'text' ? null : <span className={styles.title}>{titleOf(item)}</span>}
|
||||
<span className={styles.sub}>
|
||||
{shortDevice(device)} · {formatTime(item.ts)} · {formatSize(size)}
|
||||
{senderLabel(item)} · {formatTime(item.ts)} · {formatSize(size)}
|
||||
{receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
.pane {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
min-height: var(--bar-h);
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-2) var(--space-2) var(--space-4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
margin: var(--space-4) var(--space-2) var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 44px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.row:hover,
|
||||
.row:focus-within {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-avatar);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarFallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dim {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.who em {
|
||||
margin-inline-start: var(--space-2);
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.who strong,
|
||||
.who small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.who strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.who small {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.online {
|
||||
flex: 0 0 8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-avatar);
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.remove {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.row:hover .remove,
|
||||
.row:focus-within .remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.remove {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.note,
|
||||
.error {
|
||||
margin: var(--space-4) var(--space-2) 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.invite {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.invite label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.invite input {
|
||||
min-height: 42px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.invite .note {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inviteActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
|
||||
import type { AccountInvitation } from '@/core-bridge/account';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import type { SenderInfo } from '@/core-bridge/types';
|
||||
import { useToast } from '@/store/toast';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './MembersPane.module.css';
|
||||
|
||||
interface Props { owner: boolean; refreshKey: number; onInvite: () => void }
|
||||
|
||||
function Avatar({ person, name }: { person?: SenderInfo; name: string }) {
|
||||
return person?.avatar ? <img className={styles.avatar} src={person.avatar} alt="" />
|
||||
: <span className={`${styles.avatar} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(name)[0]?.toLocaleUpperCase() ?? '?'}</span>;
|
||||
}
|
||||
|
||||
function Section({ title, count, children }: { title: string; count: number; children: ReactNode }) {
|
||||
return count ? <section className={styles.section}><h3>{title} — {count}</h3><ul>{children}</ul></section> : null;
|
||||
}
|
||||
|
||||
/** 右栏「成员」页:以用户为单位,按在线 / 离线 / 待接受分组。设备只是用户的属性(在线设备数由后端按账号统计) */
|
||||
|
||||
const PRESENCE_POLL_MS = 8000;
|
||||
export function MembersPane({ owner, refreshKey, onInvite }: Props) {
|
||||
const channelId = useWorkspace((s) => s.channel?.id);
|
||||
const maxMembers = useWorkspace((s) => s.channel?.maxMembers);
|
||||
const people = useWorkspace((s) => s.people);
|
||||
const listInvitations = useWorkspace((s) => s.listInvitations);
|
||||
const revoke = useWorkspace((s) => s.revokeInvitation);
|
||||
const [members, setMembers] = useState<ChannelMember[]>([]);
|
||||
const [pending, setPending] = useState<AccountInvitation[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!channelId) return;
|
||||
try {
|
||||
setMembers(await channelAccessApi.members(channelId));
|
||||
setPending(owner ? await listInvitations() : []);
|
||||
setError('');
|
||||
} catch { setError('成员清单读取失败'); }
|
||||
}, [channelId, owner, listInvitations]);
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// 在线状态不走订阅流,成员页可见期间定时读取
|
||||
const timer = window.setInterval(() => { void refresh(); }, PRESENCE_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refresh, refreshKey]);
|
||||
|
||||
async function remove(username: string, invited: boolean) {
|
||||
if (!invited && !window.confirm(`移除成员 ${username}?对方的设备会被断开,已读取的内容无法收回。`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await revoke(username);
|
||||
useToast.getState().push({ kind: 'ok', text: invited ? `已撤销对 ${username} 的邀请` : `已移除 ${username}` });
|
||||
await refresh();
|
||||
} catch (cause) { useToast.getState().push({ kind: 'error', text: `操作失败:${errorMessage(cause)}` }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const online = members.filter((member) => member.online);
|
||||
const offline = members.filter((member) => !member.online);
|
||||
const row = (username: string, detail: string | undefined, invited: boolean, removable: boolean, isOwner = false, isOnline = false) => {
|
||||
const person = people[username];
|
||||
const name = person?.displayName || username;
|
||||
return <li className={`${styles.row} ${invited || (!isOnline && !isOwner) ? styles.dim : ''}`} key={`${invited ? 'i' : 'm'}:${username}`}>
|
||||
<Avatar person={person} name={name} />
|
||||
<span className={styles.who}><strong>{name}{isOwner ? <em>空间主</em> : null}</strong>
|
||||
{detail || name !== username ? <small>{[name !== username ? username : '', detail].filter(Boolean).join(' · ')}</small> : null}</span>
|
||||
{isOnline ? <span className={styles.online} role="img" aria-label="在线" /> : null}
|
||||
{removable ? <Button className={styles.remove} variant="ghost" disabled={busy} onClick={() => { void remove(username, invited); }}>{invited ? '撤销' : '移除'}</Button> : null}
|
||||
</li>;
|
||||
};
|
||||
const member = (item: ChannelMember) => row(item.username,
|
||||
item.devices > 1 || item.online_devices > 1 ? `${item.online_devices}/${item.devices} 台设备在线` : undefined,
|
||||
false, owner && item.role !== 'owner', item.role === 'owner', item.online);
|
||||
|
||||
return <div className={styles.pane}>
|
||||
<div className={styles.head}>
|
||||
<span>成员 {members.length}{maxMembers ? `/${maxMembers}` : ''}</span>
|
||||
{owner ? <Button variant="primary" icon="plus" onClick={onInvite}>邀请成员</Button> : null}
|
||||
</div>
|
||||
<div className={styles.scroll}>
|
||||
<Section title="在线" count={online.length}>{online.map(member)}</Section>
|
||||
<Section title="离线" count={offline.length}>{offline.map(member)}</Section>
|
||||
<Section title="待接受" count={pending.length}>{pending.map((invitation) => row(invitation.username, undefined, true, owner))}</Section>
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
{!owner ? <p className={styles.note}>只有空间主可以邀请或移除成员。</p> : null}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
.pad {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.head {
|
||||
|
||||
@@ -9,7 +9,6 @@ const SAVE_DELAY_MS = 400;
|
||||
export function Notepad({ defaultOpen = false }: { defaultOpen?: boolean }) {
|
||||
const snapshot = useWorkspace((s) => s.snapshot);
|
||||
const setSnapshot = useWorkspace((s) => s.setSnapshot);
|
||||
const editor = useWorkspace((s) => s.devices.find((d) => d.editing && !d.self)?.name);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
@@ -31,7 +30,6 @@ export function Notepad({ defaultOpen = false }: { defaultOpen?: boolean }) {
|
||||
<span className={`${styles.chev} ${open ? styles.open : ''}`}><Icon name="chevronRight" size={16} /></span>
|
||||
<span className={styles.title}>便签</span>
|
||||
{open ? null : <span className={styles.preview}>{firstLine}</span>}
|
||||
{editor ? <span className={styles.editing}>{editor} 正在编辑</span> : null}
|
||||
</button>
|
||||
{open ? (
|
||||
<textarea
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: var(--space-4);
|
||||
min-height: var(--bar-h);
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tabs button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tabs button[aria-selected="true"] {
|
||||
border-bottom-color: var(--accent);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabs span {
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import styles from './PaneTabs.module.css';
|
||||
|
||||
export type SideTab = 'files' | 'members';
|
||||
|
||||
/** 桌面右栏的「文件 / 成员」切换 */
|
||||
export function PaneTabs({ tab, memberCount, onTab }: { tab: SideTab; memberCount?: number; onTab: (tab: SideTab) => void }) {
|
||||
return <div className={styles.tabs} role="tablist" aria-label="右侧栏">
|
||||
<button type="button" role="tab" aria-selected={tab === 'files'} onClick={() => onTab('files')}>文件</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'members'} onClick={() => onTab('members')}>成员{memberCount ? <span>{memberCount}</span> : null}</button>
|
||||
</div>;
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { CopyButton } from '@/ui/CopyButton';
|
||||
import { QrCode } from '@/ui/QrCode';
|
||||
import styles from './SharePanel.module.css';
|
||||
|
||||
/** 分享入口:空间地址与(持链接加入时的)成员链接。成员与邀请在右侧「成员」页管理,这里不列人 */
|
||||
export function SharePanel({ qrOpen = false }: { qrOpen?: boolean }) {
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const [showOwner, setShowOwner] = useState(false);
|
||||
const [showQr, setShowQr] = useState(qrOpen);
|
||||
const accountAddress = channel ? `${location.origin}/c/${channel.id}` : '';
|
||||
|
||||
return (
|
||||
<aside className={styles.panel}>
|
||||
<section>
|
||||
<h2 className={styles.h}>分享</h2>
|
||||
<div className={styles.link}>
|
||||
<div className={styles.label}>账号空间地址</div>
|
||||
<code className={styles.code}>{accountAddress}</code>
|
||||
<CopyButton text={accountAddress} />
|
||||
<p className={styles.note}>此地址不包含密钥。在「成员」页邀请账号后,对方登录即可自动领取密钥包。</p>
|
||||
</div>
|
||||
{channel?.joinPolicy === 'open' ? <>
|
||||
<div className={styles.link}>
|
||||
<div className={styles.label}>成员链接</div>
|
||||
<code className={styles.code}>{channel.memberLink}</code>
|
||||
<div className={styles.row}>
|
||||
<CopyButton text={channel.memberLink} />
|
||||
<Button variant="ghost" icon="scan" aria-expanded={showQr} onClick={() => setShowQr((v) => !v)}>
|
||||
{showQr ? '收起二维码' : '二维码'}
|
||||
</Button>
|
||||
</div>
|
||||
{showQr ? <QrCode text={channel.memberLink} label="成员链接二维码" /> : null}
|
||||
</div>
|
||||
{channel.ownerLink ? (
|
||||
<div className={styles.link}>
|
||||
<div className={styles.label}>所有者链接 <span className={styles.warn}>含管理权限</span></div>
|
||||
<code className={styles.code}>{showOwner ? channel.ownerLink : '••••••••••••••••••••'}</code>
|
||||
<div className={styles.row}>
|
||||
<Button variant="ghost" icon="eye" onClick={() => setShowOwner((v) => !v)}>{showOwner ? '隐藏' : '显示'}</Button>
|
||||
<CopyButton text={channel.ownerLink} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<p className={styles.note}>
|
||||
持有链接并符合空间加入规则后,可读取空间历史内容。不隐藏 IP 与访问时间。
|
||||
</p>
|
||||
</> : null}
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
.rail,
|
||||
.open {
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 3;
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 56px;
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.count {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.countLabel {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.open {
|
||||
width: 320px;
|
||||
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-weight: 600;
|
||||
}
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user