Turtl 0.1.3 REST API 使用详解
覆盖 Turtl server 0.1.3 的 Basic 认证、用户管理、space/board/note 同步推送与增量拉取、附件上传下载、错误码与校验规则,命令回显均为实测。
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):
- 按 username 查
users表,不存在返回bad login: <username>; - 校验
active,被停用返回user inactive; - 校验失败次数是否达到
app.login.max_attemps(默认 5),窗口内返回user locked; 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 会先被认证中间件拦下,返回 403bad 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_id | board/note 必填 | 父空间 id |
data.board_id | note 必填 | 父面板 id |
data.mod | note 必填 | 修改时间戳(毫秒),整数校验用 !!parseInt,0 不合法 |
data.keys | board/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必填且必须是整数;缺参会返回 400missing \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> at JSON.parse (<anonymous>)<br> 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。
十、参考链接
- server 仓库(路由与模型源码):https://github.com/turtl/server (
controllers/、models/、helpers/auth.js、helpers/validator.js,查询日期 2026-11-02) - Turtl 产品与选型背景:《Turtl 0.1.3 介绍与选型指南》
- 部署与端口/认证环境搭建:《Turtl 0.1.3 Docker Compose 部署指南》
- 同步限额、附件目录等参数定义:《Turtl 0.1.3 服务器配置参数详解》
- 同步与共享协议细节:
models/sync.js、models/space.js、lib-permissions(turtl/server 0.1.3) - 同类 REST API 篇写法可对照:《TeamMapper 0.4.0 REST API 使用详解》、《Papermerge 3.5.3 REST API 使用详解》