11 KiB
API v1
基础路径 /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-Label |
raw 模式条目标签,≤255 B |
X-WD-Max-Downloads |
第 0 帧上带,条目的下载上限,0 为不限 |
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. 接口
GET /api/v1/info
返回服务端能力:协议版本、限制、验证码配置(captcha.provider、captcha.enabled、captcha.api_endpoint,即 Cap widget 要连接的地址)、传输能力。验证码的 secret 不会出现在这里。transports.http3 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。
POST /api/v1/channels
创建频道。服务端启用验证码时需要 X-WD-Cap;验证服务不可用时返回 503 captcha_unavailable,与「用户没通过验证」(428 captcha_required)区分。
{
"id": "<channel_id>",
"mode": "enc",
"settings": { "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:设置、模式、席位占用、oldest_seq、last_seq、时间。不占席位,任意角色可调用,用于加入前预览和核对模式。
POST /api/v1/channels/{id}/seats
加入频道,占用一个席位。幂等:同一终端重复调用返回同一席位。席位已满返回 403 seats_full。读取频道信息和 owner 管理接口不占席位;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 403 not_joined。
PATCH /api/v1/channels/{id}/settings
所有者修改设置。ttl_seconds 的修改从现在起算。
DELETE /api/v1/channels/{id}
所有者删除整个频道和全部密文。
POST /api/v1/channels/{id}/frames
追加一帧。请求体是完整封装(application/octet-stream)。
| 响应 | 含义 |
|---|---|
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。
席位
GET /api/v1/channels/{id}/seats:所有者查看(终端哈希、加密设备名、加入时间)。PUT /api/v1/channels/{id}/seats/me:终端上传加密设备名(≤256 B)。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 | 限流 |