Turtl 0.1.3 REST API 使用详解

覆盖 Turtl server 0.1.3 的 Basic 认证、用户管理、space/board/note 同步推送与增量拉取、附件上传下载、错误码与校验规则,命令回显均为实测。

TurtlTurtlREST API端到端加密笔记2026/11/02

Turtl 0.1.3 REST API 使用详解

目标:把 Turtl server 0.1.3 的 REST 接口面讲透——Basic 认证模型、用户注册/登录/确认/资料更新、space/board/note 同步推送与增量拉取、附件流式上传与代理下载、常见错误码与校验规则。文中命令与回显均为 2026-11-02 在 Ubuntu 26.04 部署实例(API 根地址 http://127.0.0.1:8144)逐条实测采集,客户端可用于自建同步器或接口联调。

一、环境信息

  • 被测服务:Turtl server 0.1.3(Node.js/Express,源码版本 server/package.json 的 version 字段)
  • API 根地址:http://127.0.0.1:8144(服务端进程监听 8181,经宿主端口映射对外)
  • 数据库:PostgreSQL 11
  • 测试账号:admin@turtl.local(用户 id=1)、d5demo@turtl.local(用户 id=5)
  • 测试凭据:auth blob dGVzdC1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg(admin)与 ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg(d5demo)
  • 调用工具:curl、python3(仅用于解析 JSON 摘要)
  • 查询/实测日期:2026-11-02

💡 说明:Turtl 的凭据不是明文密码,而是客户端生成的高熵 auth blob。为便于复现,本文用固定 base64 串模拟该 blob;真实客户端由密钥体系生成,服务器落库的是 pbkdf2(auth, app.secure_hash_salt, 2 次迭代) 的哈希值。

二、接口全景与通用约定

2.1 请求约定

  • 数据格式:请求与响应均为 JSON(附件上传/下载除外),请求头需带 Content-Type: application/json。
  • 认证头:Authorization: Basic base64(username:auth-blob),curl 用 -u 'username:auth-blob' 自动完成编码。
  • 客户端标识:X-Turtl-Client: web:linux 仅用于 analytics 统计,不参与鉴权;可省略。
  • 响应风格:成功直接返回数据本体(无 code/data 包裹);失败统一返回 {"error":{"message":"错误说明"}} 并按错误类型给 HTTP 状态码。
  • 请求体上限:JSON 24MB、urlencoded 4MB(源码 server.js 的 body-parser 配置);附件走流式通道,不受 JSON 上限约束。

2.2 路由全表

以下 30 条路由取自 0.1.3 源码各控制器(controllers/users.js、sync.js、spaces.js、files.js、feedback.js、errlog.js、cla.js、health.js、bookmarking.js):

方法路径域说明
GET/公共欢迎语,返回 {"greeting":"Hi."}
GET/health/[a-z0-9]+公共健康检查,实测 /health/db 返回 {"healthy":true}
POST/users公共注册(join)
GET/users/confirm/:email/:token公共邮件确认链接,307 跳转
GET/users/delete/:email/:token公共邮件删除链接,307 跳转
POST/users/delete/:email公共发起邮件删除流程
POST/cla/sign公共贡献者协议签署
POST/auth认证校验 Basic 凭据并更新最后登录时间
GET/users/:user_id认证查本人资料(含 profile_size)
GET/users/email/:email认证按邮箱查用户(不返回 body)
GET/users/:user_id/profile-size认证只返回档案字节数(裸数字)
PUT/users/:user_id认证全量更新资料(user+auth+keychain 三件套)
POST/users/confirmation/resend认证重发确认邮件
DELETE/users/:user_id认证删除本人账号及其独占数据
POST/sync认证批量推送同步记录
GET/sync认证按 sync_id 增量拉取
GET/sync/full认证全量拉取档案
PUT/notes/:note_id/attachment认证流式上传附件
GET/notes/:note_id/attachment认证取附件下载地址
GET/notes/:note_id/local-attachment-proxy认证服务端代理下载本地附件
POST/spaces/:space_id/invites认证发送空间邀请
PUT/spaces/:space_id/invites/:invite_id认证修改邀请
POST/spaces/:space_id/invites/accepted/:invite_id认证接受邀请
DELETE/spaces/:space_id/invites/:invite_id认证删除邀请
PUT/spaces/:space_id/members/:user_id认证修改成员角色
DELETE/spaces/:space_id/members/:user_id认证移除成员
PUT/spaces/:space_id/owner/:new_user_id认证转移空间所有者
POST/feedback认证反馈
POST/log/error认证客户端错误上报
GET/bookmark认证书签抓取代理,仅当配置显式开启 bookmarking 控制器时注册

💡 说明:/bookmark 在默认配置下不会注册(控制器加载清单里没有 bookmarking),调用会得到 404;其余 29 条为 0.1.3 实际加载的路由。

2.3 认证白名单

认证中间件对白名单内的路由直接放行,其余全部校验 Basic 凭据。白名单(源码 helpers/auth.js)为:GET /、POST /users、GET /users/confirm/:email/:token、POST /cla/sign、GET /health/[a-z0-9]+、GET /users/delete/:email/:token、POST /users/delete/:email。

白名单的匹配是「方法 + 完整 URL 正则」:/health 没有子路径,不在白名单;实测未认证访问返回 403:

curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:8144/health
{"error":{"message":"bad login: (bad auth)"}}
HTTP 403

而 /health/db 正常放行:

curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:8144/health/db
{"healthy":true}
HTTP 200

三、认证模型

3.1 Basic 凭据的组成

Authorization: Basic 解码后是 username:auth 两段。服务端流程(models/user.js 的 check_auth):

  1. 按 username 查 users 表,不存在返回 bad login: <username>;
  2. 校验 active,被停用返回 user inactive;
  3. 校验失败次数是否达到 app.login.max_attemps(默认 5),窗口内返回 user locked;
  4. pbkdf2(auth, app.secure_hash_salt, 2 次迭代, base64) 与库中 auth 做常量时间比较,不一致返回 bad login。

认证成功返回 JSON 字符串形式的用户 id(不是对象):

curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:8144/auth \
  -u 'admin@turtl.local:dGVzdC1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg'
"1"
HTTP 200

3.2 认证失败的三种回显

错误 auth:

curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:8144/auth \
  -u 'd5demo@turtl.local:WRONG-AUTH'
{"error":{"message":"bad login"}}
HTTP 403

不带任何凭据访问受保护路由:

curl -s -w '\nHTTP %{http_code}\n' 'http://127.0.0.1:8144/sync?sync_id=0'
{"error":{"message":"bad login: (bad auth)"}}
HTTP 403

⚠️ 注意:bad login(凭据错)与 bad login: (bad auth)(没带凭据)文案不同,可作为客户端区分「该不该重新登录」的信号。连续失败计数落 users.login_failed_count/login_failed_last,达到 5 次后即使凭据正确也会返回 user locked,锁定窗口默认 60 秒。

3.3 凭据与盐的运维约束

app.secure_hash_salt 是全局机密:既用于 auth 哈希,也用于服务端私有数据加密。更换盐值后,所有已有账号的 auth 校验立即失效。服务端部署时必须用长随机值(如 openssl rand -hex 32)并备份;客户端侧只需保管 auth blob,丢失即无法登录。

四、用户域接口

4.1 注册(POST /users)

请求体最小集为 username(须含 @,服务端转小写)与 auth;data 可携带 pubkey、name、body、avatar,全部经 vlad 校验,未知字段会被删除。

curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:8144/users \
  -H 'Content-Type: application/json' \
  -H 'X-Turtl-Client: web:linux' \
  -d '{"username":"d5demo@turtl.local","auth":"ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg","data":{"pubkey":"d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5","name":"d5demo","body":"e30:RDUgZGVtbyBib2R5"}}'
{"id":"5","username":"d5demo@turtl.local","active":true,"confirmed":false,"confirmation_token":"5613588cb4ec7d1b61d8f51d2b5e2ebeeaf19c2e19104ed31263a1312d0fd543794be8e1ed827b0bf1496c9def2f7ec146e2be1f39bed5c58a4778aad2c1b613","data":{"body":"e30:RDUgZGVtbyBib2R5","name":"d5demo","pubkey":"d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5"},"last_login":"2026-11-02T09:58:52.536Z","login_failed_last":null,"login_failed_count":0,"created":"2026-11-02T09:58:52.536Z","updated":"2026-11-02T09:58:52.536Z"}
HTTP 200

关键字段:

  • id:服务端分配的用户 id,后续 /users/:id、同步记录里的 user_id 都引用它。
  • confirmation_token:128 位十六进制确认令牌,对应 GET /users/confirm/:email/:token;邮件插件关闭时不会真的发信,但令牌照样生成。
  • confirmed:false:新账号默认未确认。未确认账号不能接受共享邀请(allow_unconfirmed_invites 只影响「发邀请」)。

注册错误场景实测:

# 重复注册
curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:8144/users \
  -H 'Content-Type: application/json' \
  -d '{"username":"d5demo@turtl.local","auth":"ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg"}'
# 缺少 auth
curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:8144/users \
  -H 'Content-Type: application/json' \
  -d '{"username":"nodata@turtl.local"}'
# 非法邮箱
curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:8144/users \
  -H 'Content-Type: application/json' \
  -d '{"username":"not-an-email","auth":"ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg"}'
{"error":{"message":"the account \"d5demo@turtl.local\" already exists"}}
HTTP 403
{"error":{"message":"missing `auth` key"}}
HTTP 400
{"error":{"message":"please enter a valid email"}}
HTTP 400

4.2 查询本人资料

GET /users/:user_id 只能查自己,越权访问实测返回 500:

curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:8144/users/1 \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg'
{"error":{"message":"you can't grab another user's info"}}
HTTP 500

查询本人返回明文资料与 profile_size(密文体积之和,单位字节):

curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:8144/users/5 \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg'
{"body":"e30:RDUgdXBkYXRlZA==","name":"d5demo","pubkey":"d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5","id":"5","username":"d5demo@turtl.local","confirmed":true,"profile_size":37}
HTTP 200

profile-size 端点返回裸数字(Content-Type: application/json,内容就是数字):

curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:8144/users/5/profile-size \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg'
29
HTTP 200

上述 29 与 GET /users/5 的 37 不同,是两次调用发生在资料更新前后:profile-size 只统计空间密文体积,profile_size 字段还叠加资料本身的变化,数值会随同步内容增长。

按邮箱查用户(任何已登录用户可查,响应不含 body):

curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:8144/users/email/d5demo@turtl.local \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg'
{"name":"d5demo","pubkey":"d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5","id":"5","username":"d5demo@turtl.local","confirmed":false}
HTTP 200

4.3 确认账号

确认路由在公共白名单内,不需要认证头;成功与失败都走 307 跳转到 app.www_url 配置的前端页面。用注册返回的完整 128 位令牌实测:

curl -s -o /dev/null -w 'HTTP %{http_code} redirect=%{redirect_url}\n' \
  'http://127.0.0.1:8144/users/confirm/d5demo@turtl.local/5613588cb4ec7d1b61d8f51d2b5e2ebeeaf19c2e19104ed31263a1312d0fd543794be8e1ed827b0bf1496c9def2f7ec146e2be1f39bed5c58a4778aad2c1b613'
HTTP 307 redirect=https://yourdomain.com/users/confirm/success

重复使用同一令牌(或错误令牌)不会返回 4xx,而是跳到错误页并带原因:

curl -s -o /dev/null -w 'HTTP %{http_code} redirect=%{redirect_url}\n' \
  'http://127.0.0.1:8144/users/confirm/d5demo@turtl.local/deadbeef'
HTTP 307 redirect=https://yourdomain.com/users/confirm/error?err=that%20account%20has%20already%20been%20confirmed

⚠️ 注意:白名单正则要求 token 为 [a-f0-9]+(纯十六进制)。带横线、带截断省略号或含大写字符的 token 会先被认证中间件拦下,返回 403 bad login: (bad auth),不会进入确认逻辑。

4.4 全量更新资料(PUT /users/:user_id)

该端点要求请求体顶层同时具备 user、auth、keychain 三项,且 user 内必须有 username 与 body;缺一项返回 400。实测缺 auth:

curl -s -w '\nHTTP %{http_code}\n' -X PUT http://127.0.0.1:8144/users/5 \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg' \
  -H 'Content-Type: application/json' \
  -d '{"user":{"auth":"ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg","keychain":{}},"data":{"body":"e30:RDUgdXBkYXRlZA==","name":"d5demo2"}}'
{"error":{"message":"missing `auth` in update data"}}
HTTP 400

正确形态(keychain 必须与库中既有条目一致,空档案传 []):

curl -s -w '\nHTTP %{http_code}\n' -X PUT http://127.0.0.1:8144/users/5 \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg' \
  -H 'Content-Type: application/json' \
  -d '{"user":{"username":"d5demo@turtl.local","body":"e30:RDUgdXBkYXRlZA==","name":"d5demo2","pubkey":"d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5"},"auth":"ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg","keychain":[]}'
{"sync_ids":["139","140"]}
HTTP 200

响应只给本次产生的同步记录 id(资料 update 与 keychain 变更各一条),不回完整用户对象;更新后的资料需再用 GET /users/5 读取。keychain 条目数与库中不一致会返回 409,提示先清空本地数据重新同步。

4.5 重发确认邮件

curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:8144/users/confirmation/resend \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg'
true
HTTP 200

💡 说明:email 插件默认 enabled:false,确认邮件被静默跳过,但接口仍返回 true、HTTP 200。要真正收到邮件,需在服务端配置 SMTP 插件(见 《Turtl 0.1.3 服务器配置参数详解》)。

五、同步接口(space/board/note)

5.1 数据模型与同步记录

Turtl 的层级是 user → space(空间)→ board(面板)→ note(笔记),附件是 note 的附属文件。所有业务字段放在 body,格式 iv:ciphertext(冒号分隔的两段 base64),服务器原样存取、不解析内容。

同步推送的请求体是数组,每个元素是一条「同步记录」:

字段必填说明
id是客户端本次操作的临时 id,服务端在 success[].id 原样回显,用于对账
type是space / board / note / keychain / file 等,处理器按类型分发
action是add / edit / delete
data是对象本体;delete 动作至少要有 id,服务端按 id 级联删除
data.id是对象客户端 id,必须匹配 ^[a-f0-9]+$(纯十六进制,不含横线)
data.user_id是当前用户 id(整数)
data.space_idboard/note 必填父空间 id
data.board_idnote 必填父面板 id
data.modnote 必填修改时间戳(毫秒),整数校验用 !!parseInt,0 不合法
data.keysboard/note 可选共享密钥封装数组,形如 [{"id":"d5k1","type":"link","priv":"ZW5jYXBzZWRrZXk=","to":"d5d5d5d5d5d5d5d5"}]
data.has_file / data.file附件笔记必填has_file:true,file.size 为附件字节数
data.body是客户端密文,格式 iv:ciphertext

💡 技术拓展:同步协议采用「客户端加密 + 服务端只存密文 + 共享密钥封装」的零知识模型,和 Joplin 的笔记级 E2EE、Standard Notes 的零知识协议同属一类;区别在于 Turtl 把共享密钥以 keys 数组挂在 board/note 上,服务器只负责存储与分发,不参与密钥解封。

5.2 推送(POST /sync)

一次推 space、board、note 三条(顺序必须父对象在前,否则后两条会被 blocked):

curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:8144/sync \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg' \
  -H 'Content-Type: application/json' \
  -d '[
    {"id":"d5r1","type":"space","action":"add","data":{"id":"d500000000000001","user_id":5,"body":"eyJrIjoiZW5jIn0=:ZDUtc3BhY2U="}},
    {"id":"d5r2","type":"board","action":"add","data":{"id":"d500000000000002","space_id":"d500000000000001","user_id":5,"keys":[{"id":"d5k1","type":"link","priv":"ZW5jYXBzZWRrZXk=","to":"d5d5d5d5d5d5d5d5"}],"body":"eyJrIjoiZW5jIn0=:ZDUtYm9hcmQ="}},
    {"id":"d5r3","type":"note","action":"add","data":{"id":"d500000000000003","space_id":"d500000000000001","board_id":"d500000000000002","user_id":5,"has_file":false,"mod":1793613532498,"keys":[{"id":"d5k1","type":"link","priv":"ZW5jYXBzZWRrZXk=","to":"d5d5d5d5d5d5d5d5"}],"body":"eyJrIjoiZW5jIn0=:ZDUtbm90ZQ=="}}
  ]'
{"success":[{"id":"d5r1","user_id":"5","item_id":"d500000000000001","type":"space","action":"add","sync_ids":["130"],"data":{"id":"d500000000000001","body":"eyJrIjoiZW5jIn0=:ZDUtc3BhY2U=","user_id":"5","members":[{"id":"36","space_id":"d500000000000001","user_id":"5","role":"owner","created":"2026-11-02T09:58:52.638Z","updated":"2026-11-02T09:58:52.638Z","username":"d5demo@turtl.local"}]}},{"id":"d5r2","user_id":"5","item_id":"d500000000000002","type":"board","action":"add","sync_ids":["131"],"data":{"id":"d500000000000002","body":"eyJrIjoiZW5jIn0=:ZDUtYm9hcmQ=","keys":[{"id":"d5k1","to":"d5d5d5d5d5d5d5d5","priv":"ZW5jYXBzZWRrZXk=","type":"link"}],"user_id":"5","space_id":"d500000000000001"}},{"id":"d5r3","user_id":"5","item_id":"d500000000000003","type":"note","action":"add","sync_ids":["132"],"data":{"id":"d500000000000003","mod":1793613532498,"body":"eyJrIjoiZW5jIn0=:ZDUtbm90ZQ==","keys":[{"id":"d5k1","to":"d5d5d5d5d5d5d5d5","priv":"ZW5jYXBzZWRrZXk=","type":"link"}],"user_id":"5","board_id":"d500000000000002","space_id":"d500000000000001"}}],"failures":[],"blocked":[]}
HTTP 200

响应结构三块:

  • success:成功记录,含 item_id(对象 id)、sync_ids(本次写入同步日志的编号)、data(服务端回填后的完整对象)。
  • failures:本记录被处理器拒绝,带 error.code(HTTP 语义码,如 400)与 error.msg;整个请求仍返回 HTTP 200。
  • blocked:因前序记录失败而未执行、自身也没报错的记录。

⚠️ 注意:创建 space 时服务端会自动把当前用户加为 owner 成员,并在响应 data.members 中回显。父对象不存在时子对象会被 blocked,客户端必须按 space → board → note 的顺序推送。

5.3 增量拉取(GET /sync?sync_id=N)

sync_id 是单调递增的同步日志编号。客户端拿到上次的 sync_id 后,从该位置之后拉增量。首次从 0 拉取:

curl -s -w '\nHTTP %{http_code}\n' 'http://127.0.0.1:8144/sync?sync_id=0' \
  -u 'admin@turtl.local:dGVzdC1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg' \
  -H 'X-Turtl-Client: web:linux' | python3 -c "import sys,json;d=json.load(sys.stdin);print('records:',len(d['records']),'sync_id:',d['sync_id'])"
records: 3 sync_id: 3
HTTP 200

若 sync_id 小于最早记录,服务端返回全部历史;响应里的 sync_id 是当前最新编号,客户端下次应以它作为新的游标。单条记录的结构:

{"id":"130","item_id":"d500000000000001","type":"space","action":"add","user_id":"5","created":"2026-11-02T09:58:52.643Z","updated":"2026-11-02T09:58:52.643Z","data":{"id":"d500000000000001","body":"eyJrIjoiZW5jIn0=:ZDUtc3BhY2U=","user_id":"5","members":[{"id":"36","space_id":"d500000000000001","user_id":"5","role":"owner","created":"2026-11-02T09:58:52.638Z","updated":"2026-11-02T09:58:52.638Z","username":"d5demo@turtl.local"}]}}

字段含义:id 是同步日志 id(与推送请求里的临时 id 不同),item_id 是业务对象 id,type/action 是变更类型,data 是对象快照。

⚠️ 注意:sync_id 必填且必须是整数;缺参会返回 400 missing \sync_id` var`,不要用字符串或空值。

5.4 全量拉取(GET /sync/full)

不用传游标,返回当前档案的全部对象,适合新客户端首次登录或本地数据损坏后重建:

curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:8144/sync/full \
  -u 'admin@turtl.local:dGVzdC1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg' | python3 -c "import sys,json;d=json.load(sys.stdin);print('full records:',len(d['records']),'types:',[r['type'] for r in d['records']])"
full records: 4 types: ['user', 'space', 'board', 'note']
HTTP 200

与增量拉取的区别:

  • 全量里的用户记录 type=user、item_id 为字符串形式的用户 id,且 id 固定为 0;
  • 大量记录统一是 action:add,不区分历史 edit/delete;
  • 响应同样带 sync_id,可作为之后增量的起点。

5.5 查询参数 immediate 与 type

GET /sync 支持 immediate=1 与 type=<名称>:

  • immediate=1:跳过服务端聚合等待,立即返回当前已有记录;
  • 带 type 时,除 type=poll 外都会把 immediate 置为真;
  • type 不参与过滤——它只是轮询提示,不是 WHERE type=?。

实测 sync_id=0&type=note 仍返回 space/board/file 等全部类型 13 条:

curl -s 'http://127.0.0.1:8144/sync?sync_id=0&type=note' \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg' | python3 -c "import sys,json;d=json.load(sys.stdin);print('records:',len(d['records']),'types:',sorted(set(r['type'] for r in d['records'])),'latest:',d['sync_id'])"
records: 13 types: ['board', 'file', 'note', 'space', 'user'] latest: 142

💡 说明:需要按类型过滤时,客户端应在本地按 record.type 筛选,不要依赖查询参数;type 传任意字符串都不会改变返回集合,只会影响 immediate。

5.6 编辑与删除

编辑 note 用 action=edit,mod 必须递增(服务端按 mod 判断新旧);实测推送后只产生一条 note.edit 同步日志:

curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:8144/sync \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg' \
  -H 'Content-Type: application/json' \
  -d '[{"id":"d5r9","type":"note","action":"edit","data":{"id":"d500000000000003","space_id":"d500000000000001","board_id":"d500000000000002","user_id":5,"has_file":false,"mod":1793613600000,"keys":[{"id":"d5k1","type":"link","priv":"ZW5jYXBzZWRrZXk=","to":"d5d5d5d5d5d5d5d5"}],"body":"eyJrIjoiZW5jIn0=:ZDUtbm90ZS1lZGl0LTI="}}]'
{"success":[{"id":"d5r9","user_id":"5","item_id":"d500000000000003","type":"note","action":"edit","sync_ids":["141"],"data":{"id":"d500000000000003","mod":1793613600000,"body":"eyJrIjoiZW5jIn0=:ZDUtbm90ZS1lZGl0LTI=","keys":[{"id":"d5k1","to":"d5d5d5d5d5d5d5d5","priv":"ZW5jYXBzZWRrZXk=","type":"link"}],"user_id":"5","board_id":"d500000000000002","space_id":"d500000000000001"}}],"failures":[],"blocked":[]}
HTTP 200

从编辑前的游标 140 拉增量,只回这一条:

curl -s -w '\nHTTP %{http_code}\n' 'http://127.0.0.1:8144/sync?sync_id=140' \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg'
{"records":[{"id":"141","item_id":"d500000000000003","type":"note","action":"edit","user_id":"5","created":"2026-11-02T09:59:46.579Z","updated":"2026-11-02T09:59:46.579Z","data":{"id":"d500000000000003","mod":1793613600000,"body":"eyJrIjoiZW5jIn0=:ZDUtbm90ZS1lZGl0LTI=","keys":[{"id":"d5k1","to":"d5d5d5d5d5d5d5d5","priv":"ZW5jYXBzZWRrZXk=","type":"link"}],"user_id":"5","board_id":"d500000000000002","space_id":"d500000000000001"}}],"sync_id":"141"}
HTTP 200

删除 note(带附件时服务端会同时生成 file.delete):

curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:8144/sync \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg' \
  -H 'Content-Type: application/json' \
  -d '[{"id":"d5r8","type":"note","action":"delete","data":{"id":"d500000000000004","space_id":"d500000000000001","user_id":5}}]'
{"success":[{"id":"d5r8","user_id":"5","item_id":"d500000000000004","type":"note","action":"delete","sync_ids":["137","138"],"data":{"id":"d500000000000004"}}],"failures":[],"blocked":[]}
HTTP 200

删除后该对象在增量拉取里的快照会变成标记位,而不是完整 data:

{"id":"137","item_id":"d500000000000004","type":"note","action":"delete","user_id":"5","created":"2026-11-02T09:58:54.743Z","updated":"2026-11-02T09:58:54.743Z","data":{"id":"d500000000000004","deleted":true}}
{"id":"138","item_id":"d500000000000004","type":"file","action":"delete","user_id":"5","created":"2026-11-02T09:58:54.756Z","updated":"2026-11-02T09:58:54.756Z","data":{"id":"d500000000000004","deleted":true}}

中间对象的加密快照可能返回 {"missing":true},表示该对象已不可读(被删除或权限变化);客户端应据此清掉本地副本,不要把它当成损坏数据。

5.7 批量上限:32 条静默截断

服务端配置 sync.max_bulk_sync_records 默认 32,超出部分直接 slice 丢弃,不报错、不进 failures。先生成 40 条 space 测试记录,再一次性推送:

python3 -c "import json;items=[{'id':'b%d'%i,'type':'space','action':'add','data':{'id':'00000000d5d%05d'%i,'user_id':5,'body':'e30:eA=='}} for i in range(1,41)];open('d5-bulk40.json','w').write(json.dumps(items))"
curl -s -X POST http://127.0.0.1:8144/sync \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg' \
  -H 'Content-Type: application/json' -d @d5-bulk40.json | python3 -c "import sys,json;d=json.load(sys.stdin);print('success:',len(d['success']),'failures:',len(d['failures']),'blocked:',len(d['blocked']));print('first:',d['success'][0]['id'],d['success'][0]['item_id'],d['success'][0]['sync_ids']);print('last:',d['success'][-1]['id'],d['success'][-1]['item_id'],d['success'][-1]['sync_ids'])"
success: 32 failures: 0 blocked: 0
first: b1 00000000d5d00001 ['143']
last: b32 00000000d5d00032 ['174']

客户端必须自行分片(每片 ≤32 条),并按响应里出现的 success[].id 判断哪些已落库,未出现的要重推。

5.8 对象 id 与 mod 的校验

对象 id 只接受纯十六进制。用标准 UUID(带横线)实测,HTTP 仍 200,但该条进 failures:

curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:8144/sync \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg' \
  -H 'Content-Type: application/json' \
  -d '[{"id":"d5r5","type":"space","action":"add","data":{"id":"550e8400-e29b-41d4-a716-446655440000","user_id":5,"body":"e30:eA=="}}]'
{"success":[],"failures":[{"id":"d5r5","type":"space","action":"add","data":{"id":"550e8400-e29b-41d4-a716-446655440000","user_id":5,"body":"e30:eA=="},"_id":1,"error":{"code":400,"msg":"space object failed validation: field `id` is not the right type"}}],"blocked":[]}
HTTP 200

note 的 mod:0 同样失败(整数校验用 !!parseInt,0 为假):

curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:8144/sync \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg' \
  -H 'Content-Type: application/json' \
  -d '[{"id":"d5r6","type":"note","action":"add","data":{"id":"d500000000000099","space_id":"d500000000000001","user_id":5,"mod":0,"body":"e30:eA=="}}]'
{"success":[],"failures":[{"id":"d5r6","type":"note","action":"add","data":{"id":"d500000000000099","space_id":"d500000000000001","user_id":5,"mod":0,"body":"e30:eA=="},"_id":1,"error":{"code":400,"msg":"note object failed validation: field `mod` is not the right type"}}],"blocked":[]}
HTTP 200

六、附件接口

附件不是普通 JSON 字段,而是独立流式通道:先推一条 has_file:true 的 note,再向 PUT /notes/:note_id/attachment 传字节,最后用 GET 拿下载地址。

6.1 先建带 file 标记的 note

curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:8144/sync \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg' \
  -H 'Content-Type: application/json' \
  -d '[{"id":"d5r7","type":"note","action":"add","data":{"id":"d500000000000004","space_id":"d500000000000001","board_id":"d500000000000002","user_id":5,"has_file":true,"file":{"size":3072},"mod":1793613532500,"body":"eyJrIjoiZW5jIn0=:ZDUtYXR0YWNo"}}]'
{"success":[{"id":"d5r7","user_id":"5","item_id":"d500000000000004","type":"note","action":"add","sync_ids":["134"],"data":{"id":"d500000000000004","mod":1793613532500,"body":"eyJrIjoiZW5jIn0=:ZDUtYXR0YWNo","file":{"size":3072},"user_id":"5","board_id":"d500000000000002","space_id":"d500000000000001"}}],"failures":[],"blocked":[]}
HTTP 200

6.2 流式上传(PUT /notes/:note_id/attachment)

生成 3KB 测试字节并上传,必须显式声明 Content-Type: application/octet-stream:

head -c 3072 /dev/urandom > d5-attach.bin
md5sum d5-attach.bin
curl -s -w '\nHTTP %{http_code} time=%{time_total}s\n' -X PUT \
  http://127.0.0.1:8144/notes/d500000000000004/attachment \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg' \
  -H 'Content-Type: application/octet-stream' --data-binary @d5-attach.bin
2f8cbe2c68ede337d30a2e1d6bcd09ef  /opt/data/quzuo/tmp/turtl/e2e/d5-attach.bin
{"id":"d500000000000004","mod":1793613532500,"body":"eyJrIjoiZW5jIn0=:ZDUtYXR0YWNo","file":{"size":3072},"user_id":"5","board_id":"d500000000000002","space_id":"d500000000000001","has_file":true,"sync_ids":["135","136"]}
HTTP 200 time=0.032503s

上传成功会同时产生两条同步记录:note.edit(把 has_file 置真并写入 file.size)与 file.add。响应里的 file.size 以服务端实际收到的字节数为准,客户端应先算好大小再推 note,避免两边不一致。

6.3 取下载地址与代理下载

本地存储且 uploads.local_proxy:true 时,GET /notes/:note_id/attachment 返回服务端代理 URL(JSON 字符串):

curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:8144/notes/d500000000000004/attachment \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg'
"http://127.0.0.1:8144/notes/d500000000000004/local-attachment-proxy"
HTTP 200

再取代理地址,校验字节完整:

curl -s -o d5-attach-dl.bin -w 'HTTP %{http_code} bytes=%{size_download}\n' \
  http://127.0.0.1:8144/notes/d500000000000004/local-attachment-proxy \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg'
md5sum d5-attach.bin d5-attach-dl.bin
HTTP 200 bytes=3072
2f8cbe2c68ede337d30a2e1d6bcd09ef  /opt/data/quzuo/tmp/turtl/e2e/d5-attach.bin
2f8cbe2c68ede337d30a2e1d6bcd09ef  /opt/data/quzuo/tmp/turtl/e2e/d5-attach-dl.bin

💡 说明:local_proxy:false 时该端点返回 uploads.url 拼出的公开地址,由 Web 服务器直接服务附件;uploads.local:false 时返回 S3 签名/公开地址。代理模式免去 CORS 配置,代价是下载流量过服务端。

6.4 附件上传的 Content-Type 陷阱

若用 curl --data-binary 但不写 Content-Type,curl 默认发 application/x-www-form-urlencoded,body-parser 会把请求体消费掉,流式处理器收不到 data/end 事件,请求挂起直到客户端超时:

curl -s -m 15 -w '\nHTTP %{http_code} time=%{time_total}s\n' -X PUT \
  http://127.0.0.1:8144/notes/d500000000000005/attachment \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg' \
  --data-binary @d5-attach.bin
HTTP 000 time=15.002624s

服务端访问日志对应行为:

info: ::ffff:10.89.61.1 "PUT /notes/d500000000000005/attachment" - -

⚠️ 注意:这类错误没有响应码、日志状态位是 -,且可能在附件目录留下 0 字节占位文件。排障先看请求头是否声明 application/octet-stream,不要误判为服务端卡死。改用正确 Content-Type 后同一条 note 立即成功(HTTP 200,file.size:3072)。

6.5 大附件与删除级联

  • 附件走流式通道,不受 24MB JSON 限制;实测 5MB 附件上传返回 HTTP 200、file.size:5242880,落盘文件 5242880 字节。
  • 删除 note 会级联生成 file.delete 同步记录(见 5.6),附件文件由服务端清理。
  • 附件落盘位置由 uploads.local 决定;生产必须把该目录持久化,或改用 s3.* 配置,否则服务重建后附件丢失。

七、空间与共享接口

7.1 路由与动作

空间共享共 7 条路由(controllers/spaces.js):

方法路径作用
POST/spaces/:space_id/invites发送邀请,指定被邀请邮箱、角色、加密标题/正文
PUT/spaces/:space_id/invites/:invite_id修改邀请(角色、加密内容)
POST/spaces/:space_id/invites/accepted/:invite_id接受邀请,返回加入后的 space 对象
DELETE/spaces/:space_id/invites/:invite_id删除邀请
PUT/spaces/:space_id/members/:user_id修改成员角色
DELETE/spaces/:space_id/members/:user_id移除成员
PUT/spaces/:space_id/owner/:new_user_id转移空间所有者

邀请实测响应(发送方为空间 owner,被邀请人尚未确认):

{"id":"ab12cd34ef560718","body":"ZW5j:Ym9keQ==","role":"editor","title":"ZW5j:dGl0bGU=","to_user":"share@turtl.demo","space_id":"f00d000000000001","is_pubkey_protected":true,"is_passphrase_protected":false,"sync_ids":["47","48"]}

⚠️ 注意:被邀请人未确认账号时接受邀请返回 403 you must confirm your account to accept an invite;确认后同一邀请可正常接受,响应回显 space 的 members 数组(含 owner 与新成员)。

7.2 角色与权限

角色取值与权限矩阵(源码 lib-permissions,0.1.3 实测输出):

角色权限集合
owner编辑空间、增删成员、增删邀请、增删面板、增删笔记、转移所有者、删除空间
admin编辑空间、增删成员、增删邀请、增删面板、增删笔记(不能转移所有者/删空间)
moderator增删面板、增删笔记
member增删笔记
guest无

💡 说明:把成员角色改成未在矩阵中的值仍会被服务端接受并落库,但权限判定会落到空集合;客户端应只使用上表五个合法角色。邀请默认角色为 editor 是历史值,实际权限按落库后映射结果执行。

八、错误码与校验规则

8.1 HTTP 状态码

状态码典型场景响应示例
200成功;同步单条校验失败也是 200{"success":[{"id":"d5r1"}],"failures":[],"blocked":[]}
307确认/删除邮件链接跳转前端Location: https://yourdomain.com/users/confirm/success
400缺必填字段、邮箱格式错、对象字段类型错(未捕获到用户的路径){"error":{"message":"missing \auth` key"}}`
403重复注册、凭据错、账号锁定、未确认账号接受邀请、未认证访问{"error":{"message":"bad login"}}
409资料更新时 keychain 与库中不一致{"error":{"message":"the given keychain doesn't match what is in your profile. try clearing local data and try again/"}}
500越权查他人资料、未包装的普通异常{"error":{"message":"you can't grab another user's info"}}

8.2 同步失败的结构

POST /sync 的 HTTP 层几乎总是 200,真正的失败在 failures[]:

{"success":[],"failures":[{"id":"d5r5","type":"space","action":"add","data":{"id":"550e8400-e29b-41d4-a716-446655440000","user_id":5,"body":"e30:eA=="},"_id":1,"error":{"code":400,"msg":"space object failed validation: field `id` is not the right type"}}],"blocked":[]}
  • error.code 是业务码(400/403/…),不是 HTTP 状态码;
  • _id 是服务端给该条记录分配的序号(从 1 开始);
  • 若前一条失败,后续未执行且无自身错误的记录进 blocked,客户端可原顺序重试。

8.3 坏 JSON 的返回

请求体 JSON 语法错误时,body-parser 抛 SyntaxError,Express 默认错误页返回 HTTP 400(实测 775 字节 HTML,非 JSON),且访问日志没有对应行:

curl -s -m 10 -o bad-json.html -w 'HTTP %{http_code} bytes=%{size_download}\n' \
  -X POST http://127.0.0.1:8144/sync \
  -u 'd5demo@turtl.local:ZDUtZGVtby1hdXRoLWJsb2ItMDEyMzQ1Njc4OWFiY2RlZg' \
  -H 'Content-Type: application/json' -d '[{"id":"x","type":"note",}]'
HTTP 400 bytes=775

响应体开头:

SyntaxError: Unexpected token } in JSON at position 25<br> &nbsp; &nbsp;at JSON.parse (&lt;anonymous&gt;)<br> &nbsp; &nbsp;at parse (/app/node_modules/body-parser/lib/types/json.js:88:17)

💡 说明:坏 JSON 的错误页内嵌堆栈,属于信息泄露面;反向代理层可统一拦截 400 HTML 响应再对外返回简洁 JSON。

8.4 校验规则速查

规则说明实测表现
id^[a-f0-9]+$,纯十六进制带横线 UUID 进 failures,code 400
mod!!parseInt(value),0 与空串为假mod:0 进 failures,code 400
username必须含 @,服务端转小写无 @ 返回 400 please enter a valid email
body必须是字符串非字符串返回 400 failed validation
未知字段vlad 校验后删除不会落库,也不报错
JSON 体24MB 上限超出由 body-parser 413/连接异常处理
urlencoded 体4MB 上限附件上传必须避开该 Content-Type
批量条数默认 32超出静默丢弃,见 5.7
越权查询只能查本人500 you can't grab another user's info

九、常见问题

Q1:为什么 /auth 返回的是 "1" 或 "5",而不是用户对象? POST /auth 只做凭据校验并更新最后登录时间,响应是 JSON 字符串形式的用户 id;完整资料要走 GET /users/<id>。

Q2:推送返回 HTTP 200,但数据没落库? 先看 failures 与 blocked。HTTP 200 只代表请求被受理;对象 id 带横线、mod 为 0、父对象不存在等都会以 per-item 错误返回。

Q3:一次推 40 条,服务端只处理了 32 条? sync.max_bulk_sync_records 默认 32,超出部分静默 slice。客户端按 ≤32 分片,并核对 success[].id。

Q4:附件上传一直超时,日志状态位是 -? 请求头没写 Content-Type: application/octet-stream,curl 默认的 urlencoded 被 body-parser 消费,流式处理器收不到事件。补上 Content-Type 重试即可。

Q5:同步拉取返回 {"missing":true}? 该对象已被删除或当前用户失去权限,快照不可读。客户端应删除本地副本;删除事件本身会以 {"id":"d500000000000004","deleted":true} 出现。

Q6:type=note 拉取仍返回 space/board? type 不是过滤参数,只影响 immediate。类型筛选必须在客户端做。

Q7:确认链接返回 403 bad login: (bad auth)? 白名单正则要求 token 为纯十六进制且完整。URL 里的 token 带横线、被截断、含大写字符都会先被认证中间件拦下。

**Q8:资料更新报 missing \keychain` in update data?** PUT /users/:id要求顶层user、auth、keychain 三件套,缺一不可;keychain` 还需与库中条目数量一致,否则返回 409。

十、参考链接

Turtl 0.1.3 REST API 使用详解 - Quzuo