Files
WebDrop/docs/api.md
T

21 KiB
Raw Blame History

API 契约 6.0

账户资料使用 GET/PATCH /accounts/profile;头像为 PNG/JPEG/WebP data URI,禁止远程 URL 和 SVG。不提供旧资料导入。 空间结构使用 GET /spaces/{id}/structure,包含 revision、channels、groups、folders、locations。频道与分组分别通过 /spaces/{id}/channels 和 /spaces/{id}/groups 创建;响应同时包含完整结构快照和服务端新建实体及 ID。其他字段通过 PATCH 重命名;频道归属通过 PUT /spaces/{id}/channels/{channelId}/group 修改。文件夹通过 /spaces/{id}/folders 创建及 PATCH 重命名,文件归属通过 PUT /spaces/{id}/locations 修改。 频道、分组和目录写操作需要账号会话和空间写权限;允许上传的访客可以关联和移动自己的资源,文件移动必须满足每个条目的作者或管理权限,跨空间、跨频道的错误分组/目录关联被拒绝。结构修改后通知订阅设备重新拉取。

URL 基础路径保留 /api/v1,/info.contract_version 为 6.0.0。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;公开链接仍允许访客按频道加入规则访问。

GET /info 的 defaults 包含 channel、share 创建设置;省略创建请求的 settings 时服务端使用相同默认值。choices.seats 与 limits.max_seats 表示成员人数范围,不代表设备数。频道设置 JSON 字段 seats 为保持已有频道配置可读而保留其名称;v5 起它限制的是成员数。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。

基础路径 /api/v1。所有请求和响应的二进制数据格式见 protocol.md。前端托管在独立域名,服务端需要配置 CORS 白名单。

1. 鉴权与通用头

头 说明
X-WD-Role read / write / owner,指明 Authorization 里的令牌属于哪种角色
Authorization Bearer <base64url(令牌)>
X-WD-Endpoint base64url(S),终端密钥,用于席位
X-WD-Cap 创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11)
X-WD-Max-Downloads 第 0 帧上带,条目的下载上限,0 为不限
X-WD-Account 账号 session token。与频道/分享的能力令牌分开;创建、管理和账号成员请求必填,访客省略
X-WD-Event-* 已登记签名设备提交频道对象 FINAL 帧时必填,绑定对象 ID、域/epoch、事件类型与完整密文帧 manifest,见 protocol §21

raw 模式的读请求不需要令牌,但仍需要 X-WD-Endpoint。

WebSocket 无法设置头,令牌通过 Sec-WebSocket-Protocol 传递:wd.v1, wd.role.<role>, wd.auth.<base64url(令牌)>, wd.ep.<base64url(S)>,服务端回选 wd.v1。不要放进 URL,否则会被代理记录。

2. 权限矩阵

操作 需要
读日志、拉取条目 read(enc)或知道 id(raw),并占有席位
追加帧 write(members_can_upload 为 true 时)或 owner
删除条目 owner;delete_policy=own 时终端哈希等于条目作者;anyone 时任意已入席终端
改设置、删频道、看和释放席位 owner;这些管理操作不占席位

3. 接口

账号与保险库

用户名为 3–32 位 ASCII 字母、数字、_ 或 -,大小写不敏感。客户端 PBKDF2-SHA256(600,000 次)派生口令根,再分别派生登录凭据和 AES-GCM 保险库密钥。登录凭据仅经 HTTPS 发送;服务端再用随机 salt 和 PBKDF2-SHA256(210,000 次)存储慢哈希,数据库里的哈希不能直接用于登录。服务端不接收口令根或保险库密钥。二进制字段使用标准 Base64,保险库密文最大 1.3 MB。

  • POST /api/v1/accounts 注册 {username,salt,verifier,vault,public_key,signing_public_key}。public_key 是 P-256 ECDH 公钥,signing_public_key 是 P-256 ECDSA 账号根公钥;对应私钥只保存在客户端加密保险库中。API v6 不兼容旧账号保险库协议,不自动迁移旧签名身份。
  • POST /api/v1/accounts/salt 请求 {username},返回 {salt},供客户端派生登录凭据。
  • POST /api/v1/accounts/session 请求 {username,verifier,device_id?,device_name?}。成功返回 {username,token};session token 随机生成,只保存 SHA-256,期限 30 天。
  • GET /api/v1/accounts/vault 使用 Authorization: Bearer <token> 读取 {revision,vault}。
  • PUT /api/v1/accounts/vault 使用同一鉴权,发送 {revision,vault}。revision 乐观并发控制;冲突返回 409 vault_conflict,客户端必须先读取并合并,不能静默覆盖。
  • DELETE /api/v1/accounts/session 撤销当前 bearer session;退出登录时调用。
  • GET /api/v1/accounts/users/{username}/key 已登录用户查询 ECDH 邀请公钥和可选的账号签名根公钥。
  • PUT /api/v1/accounts/devices/signing-key 当前设备提交 {device_id,public_key,signature}。服务端核对账号 session 绑定的 device_id,并验证账号根密钥对 P-256 设备公钥证书的签名后保存。
  • GET /api/v1/accounts/users/{username}/key 返回账号 ECDH 公钥与签名根公钥;签名对象携带设备公钥和根签设备证书,客户端首次 TOFU 固定根公钥后离线验设备证书。
  • GET /api/v1/accounts/invitations 返回当前账号的邀请,payload 为绑定收件用户名和频道 ID 的 ECDH + HKDF + AES-GCM 密文。

没有邮箱找回流程。忘记密码后保险库无法恢复。保险库仅同步资源凭据和草稿清单,不同步设备 S、设备名、缓存、原始文件或发送队列。浏览器存储按账号与访客分库;切换账号会中止订阅并清空内存会话。资源清单移除使用带时间戳的墓碑同步,避免旧设备将条目恢复。

签名根密钥保存在加密保险库;设备签名私钥以不可导出的 CryptoKey 保存在本机账户数据库,不随保险库同步。已登记签名设备写入 Workspace 对象时,FINAL 帧和删除请求必须带签名事件;尚未把签名要求扩展到频道结构和成员/权限变更。

GET /api/v1/info

返回服务端能力:协议版本、限制、验证码配置(captcha.provider、captcha.enabled、captcha.api_endpoint,即 Cap widget 要连接的地址)、传输能力以及创建默认值/选项。defaults.channel_mode、defaults.channel 和 choices 是前端创建表单的初始值与可选项;服务端独立校验提交的设置。验证码的 secret 不会出现在这里。transports.http3 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。

POST /api/v1/channels

创建者必须登录。创建请求必须提供 settings.name,服务端拒绝缺失、空白或纯空格名称并返回 400 space_name_required,存储时去除首尾空格;名称长度最多 48 个字符。只有一套频道/成员/线程模型,mode=enc|raw 仅决定客户端内容是否端到端加密,不改变业务权限。创建者自动写入 owner 成员记录并占一个成员名额,同时登记当前设备终端;该账号的其他设备不增加名额。服务端启用验证码时还需要 X-WD-Cap。

{
  "id": "<channel_id>",
  "mode": "enc",
  "settings": { "name": "工作设备", "seats": 2, "members_can_upload": true, "delete_policy": "own",
                "ttl_seconds": 86400, "max_items": 1000, "file_max_downloads": 10 },
  "read_hash": "<sha256(T_read)>",
  "write_hash": "<sha256(T_write)>",
  "owner_hash": "<sha256(T_owner)>"
}

raw 模式省略 read_hash;write_hash 仅在成员可上传时提供。创建者的终端(X-WD-Endpoint)自动入席。返回 201,响应体与「读取频道信息」相同(ChannelInfo)。

GET /api/v1/channels/{id}

返回 ChannelInfo:设置(包含所有成员共享的频道名)、模式、成员名额占用(seats_used)、oldest_seq、last_seq、时间。seats_used 统计唯一账号成员和匿名访客终端,不统计账号的设备数。不占成员名额,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。

POST /api/v1/channels/{id}/seats

为一个终端加入频道。每个设备终端各自建立席位以便认证、重命名和撤销,但席位不再受人数上限约束。账号第一次加入时占一个成员名额;同一账号的更多设备不增加名额。同一设备重复调用幂等。成员名额已满时,新的账号成员或访客终端返回 403 seats_full。匿名访客无法跨设备识别,每个访客终端各计一个成员名额。读取频道信息和 owner 管理接口不占成员名额;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 403 not_joined。

可发送 JSON {password}。默认 join_policy=open,allow_guests=true,允许持链接的陌生人设备加入。allow_guests=false 要求账号登录;join_policy=invite 要求该账号已受邀。频道主、已加入的账号成员和受邀账号无需再次输入频道密码。密码由服务端保存加盐 PBKDF2 哈希,从不通过频道信息返回。成员账号和设备终端身份独立:同一账号可拥有不限数量的设备席位,但只占一个成员名额;账号成员的读写请求必须出示对应账号 session 和该设备 S。

GET /channels/{id}/members 返回频道内账号成员及 owner|member 角色。邀请前,频道主调用 GET /channels/{id}/invitations/{username}/crypto-plan 获取服务端根据成员权限和历史设置计算的域/epoch/收件人计划。浏览器只为计划中的收件人生成并封装域密钥;随后 POST /channels/{id}/invitations 原子提交账号链接包与 domain_envelopes。服务端会重新计算计划并校验每个 epoch 和收件人,不接受前端自行增加授权或选择轮换范围。DELETE /channels/{id}/invitations/{username} 撤销邀请、成员授权、域密钥包与该账号的设备席位,并阻止重新加入,直到再次邀请。不能移除频道主。设备面板踢出访客也会阻止该 S 重新加入。

频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号或访客设备、频道成员、设备席位与写入权限,不能靠客户端隐藏按钮绕过。移除单台设备只撤销该终端,不释放账号成员名额;移除账号成员才会释放名额。WebSocket 账号 session 经 wd.account.<base64url(token)> 子协议传递,并在推送循环中重新检查有效性。

PATCH /api/v1/channels/{id}/settings

所有者修改设置。可通过 name 更新共享频道名,传空字符串清除名称;seats 调整成员人数上限,不能改变已有成员身份;ttl_seconds 的修改从现在起算。

DELETE /api/v1/channels/{id}

所有者删除整个频道和全部密文。

POST /api/v1/channels/{id}/frames

追加一帧。请求体是完整封装(application/octet-stream)。当账号设备已登记签名证书且对象在 /spaces/{id}/crypto/objects 声明时,最终帧必须携带 X-WD-Event-Id/Kind/Channel/Domain/Epoch/Frames/Manifest/Signature(编辑可带 X-WD-Event-Target)。服务端验签并核对此前帧摘要与当前 FINAL 帧的清单;缺少或无效签名拒绝提交。签名随对象元数据进入 CryptoState,成员读取时仍按数据域权限过滤。

响应 含义
201 {"seq"} 新追加
200 {"seq"} 幂等重发,返回原 seq
409 同一 (item_id, frame_index) 内容不同
403 无权、成员人数已满、模式或 ENC 标志不符
413 超出限制
429 限流

GET /api/v1/channels/{id}/log?from=<seq>&limit=&wait=<秒>

按 seq 拉取日志,返回记录的线格式拼接(application/octet-stream)。wait 大于 0 时为长轮询,最长 25 秒。满足 0 < from < oldest_seq 时返回 410 {"error":"gone","oldest_seq":N},客户端必须从 from=0 全量同步并丢弃本地已不存在的条目(不是从 oldest_seq 读起,见 protocol §8)。from=0 永远允许。

GET /api/v1/channels/{id}/ws?from=<seq>

WebSocket 订阅(降级链的第一级)。服务端向下推送:

  • 二进制消息:一条日志记录。
  • 文本消息(JSON 控制消息):{"t":"hb","seq":N} 心跳,{"t":"settings"} 设置变更,{"t":"seats"} 席位变更。

上传仍走 HTTP POST,便于幂等重试。

GET /api/v1/channels/{id}/events?from=<seq>

SSE 订阅(降级链的第二级)。记录以 base64url 放在 data: 中,id: 是 seq,断线后用 Last-Event-ID 或 from 续传。由于 EventSource 不能设置头,前端用 fetch 流式读取。

降级与重连

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 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。

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}:所有者移除该设备的席位,其活动订阅随后会被拒绝;账号成员身份和人数名额保留。撤销账号邀请/成员会移除该账号全部设备并释放成员名额。

4. 便捷分享接口

便捷分享是与频道并列的另一种资源,语义见 protocol.md §15。没有 WebSocket、SSE、日志、席位和成员上传。接口前缀 /api/v1/shares,只支持 enc 模式,鉴权头只用 read 和 owner 两种角色(write 会被拒绝为 bad_request)。

接口 角色 阶段 说明
POST /shares 无(需验证码) 创建草稿。可带 X-WD-Endpoint:这个终端是创建者,不占访客名额
PATCH /shares/{id}/settings owner 草稿 改有效期、访客上限、默认下载次数
POST /shares/{id}/frames owner 草稿 追加帧。格式与频道的帧完全相同;不允许 SNAPSHOT 标志
DELETE /shares/{id}/items/{item_id} owner 草稿 移除条目
DELETE /shares/{id} owner 草稿 删除整个草稿
POST /shares/{id}/finish owner 草稿 封存,一次性、不可撤销
GET /shares/{id} read 已封存 分享信息和每个文件的剩余下载次数(owner 在草稿阶段也可读)
GET /shares/{id}/manifest read 已封存 清单条目的封装字节;首次读取登记访客
GET /shares/{id}/items/{item_id}/frames?from=<index> read 已封存 下载文件,from=0 计一次下载;首次读取同样登记访客

POST /api/v1/shares

{
  "id": "<share_id>",
  "read_hash": "<sha256(T_read)>",
  "owner_hash": "<sha256(T_owner)>",
  "settings": { "ttl_seconds": 86400, "max_visitors": 0, "file_max_downloads": 10 }
}

需要验证码(X-WD-Cap,与创建频道相同的规则),并受创建限流约束。返回 201 ShareInfo。草稿的 expires_at 是创建后 24 小时(服务端配置 WebDrop:ShareDraftTtlSeconds);ttl_seconds 是封存之后的有效期,封存时才开始计时。

设置 默认 范围
ttl_seconds 86400 60 ~ 604800
max_visitors 0(不限) 0 ~ 10000
file_max_downloads 10 0(不限)或更大

POST /api/v1/shares/{id}/frames

请求体是完整的帧封装,响应沿用频道的 201/200 {"seq"} 结构。便捷分享没有日志游标,这里的 seq 是该帧的 frame_index,用于确认追加与幂等重发。内容冲突返回 409 conflict,超限返回 413 too_large。额外规则:SNAPSHOT 标志返回 400 bad_request;分享已封存返回 409 sealed。第 0 帧可带 X-WD-Max-Downloads 设置该文件的下载次数上限。

POST /api/v1/shares/{id}/finish

{ "manifest_item_id": "<b64url>" }

按 protocol §15.5 校验后封存。成功返回 200 ShareInfo(此时 state 为 sealed)。清单条目不存在、不是单帧非 GATED 的完整条目,或草稿里还有没传完的条目,返回 400 bad_request。已封存的分享再次调用返回 409 sealed。

GET /api/v1/shares/{id}

{
  "id": "…", "state": "sealed",
  "expires_at": 1700000000000, "created_at": 1699990000000, "sealed_at": 1699999999000,
  "manifest_item_id": "…",
  "settings": { "ttl_seconds": 86400, "max_visitors": 0, "file_max_downloads": 10 },
  "items": [ { "item_id": "…", "bytes": 12345, "frames": 3, "max_downloads": 5, "downloads_left": 4 } ]
}

downloads_left 为 null 表示不限。响应永远不包含访客人数。草稿对读角色返回 404 not_found。

GET /api/v1/shares/{id}/manifest

返回清单条目的完整封装(application/octet-stream),必须带 X-WD-Endpoint。登记访客;访客名额已满且这个终端未登记时返回 403 visitors_full。

GET /api/v1/shares/{id}/items/{item_id}/frames

与频道的 GATED 拉取一致:from=0 计一次下载;from>0 只有同一 X-WD-Endpoint 此前对同一文件成功启动过 from=0 才能续传,否则返回 410 gone 且不登记访客。达到上限后返回 410 gone,5 分钟宽限期内已开始的拉取可继续续传。必须带 X-WD-Endpoint,同样会登记访客。

5. 错误

错误响应体是 JSON {"error": "<code>"},不回显任何用户提交的内容。

code HTTP 含义
bad_request 400 封装格式或参数错误
unauthorized 401 令牌缺失或错误
forbidden 403 角色不够
not_joined 403 终端未入席,或席位已被所有者释放
visitors_full 403 便捷分享的访客名额已满,且这个终端未登记
sealed 409 便捷分享已封存,不能再修改;封存后再用所有者令牌发请求也是这个错误
seats_full 403 成员人数已满
mode_mismatch 403 帧的 ENC 标志与频道模式不符
not_found 404 频道或条目不存在,或已过期
conflict 409 幂等冲突
gone 410 已删除、下载达上限,或 0 < from < oldest_seq(需要从 0 全量同步)
too_large 413 超出限制
captcha_required 428 需要验证码:缺少 X-WD-Cap,或 token 无效、已用过
captcha_unavailable 503 验证码服务暂时不可用(服务端到 Cap 的调用失败),可稍后重试
rate_limited 429 限流

按账号恢复频道密钥

  • GET /api/v1/accounts/channels:只需 X-WD-Account,返回当前账号已加入频道的 {channel_id,role,payload,created_at} 密钥包;不返回其他账号或未接受邀请的密钥包,不占席位。
  • PUT /api/v1/channels/{id}/key-package:需要该频道 owner 能力和 owner 账号,仅更新当前 owner 自己的 {payload},用于补齐已创建频道的账号密钥包。
  • 创建频道可携带 key_package(1–16384 字符 opaque 密文),与频道和 owner 成员记录原子保存。前端必须提供;底层 API 允许省略供纯协议客户端使用。
  • 发送邀请时原子保存收件账号的空间密钥包、频道数据域 envelopes、默认权限和 epoch 更新;接受后仍可跨设备领取。撤销邀请/成员会删除服务端密钥包与访问记录,但无法收回对方此前保存的密钥,成员移除后的 epoch 轮换仍未实现。删除空间时清理全部密钥包。

账号设备与资料

GET /accounts/devices 返回本账号设备 {id,name,created_at,last_seen,current}。PATCH /accounts/devices/{id} 发送 {name};DELETE 撤销该设备全部登录 sessions 与频道席位,不移除账号成员。新客户端登录时携带稳定浏览器 device_id(UUID)和设备名,各设备单独记录。个人名称/头像存储在客户端加密保险库,修改后按 modifiedAt 合并,不写入服务端明文账号表。昵称不改变固定用户名或参与密钥派生。