自建同步服务端 · 单个 Python 文件 · 仅标准库

EsprinSync

Note, Nothing.

多账户、操作日志、可复用 ID、令牌鉴权与管理后台,并托管网页版客户端。 数据以「操作(put / del)」为单位追加到日志,把日志从头重放一遍即为全部数据。

主体为 sync.py,除 Python 3 标准库外没有依赖,不连接任何外部服务; 未配置凭据时同步接口一律返回 401。

服务端不保存以服务端为准的笔记副本 · 客户端只记录「已应用到第几号」

python sync.py · 启动输出
[INFO] [Server] 启动 EsprinSync v1
[INFO] [Server] listen=127.0.0.1:8686 hostSource=default portSource=default
[INFO] [Sync] endpoint=/sync/* origin=http://127.0.0.1:8686
[INFO] [Admin] endpoint=/admin dir=/srv/esprin/manager api=/admin/api
[INFO] [Auth] tokenSource=accounts accountCount=1 tokenCount=0
形态
单个 Python 文件(sync.py)
依赖
仅标准库,无数据库
账户
多账户,内置 admin
数据
journal.log,一行一条

01特性

日志即数据

服务端只有一份操作日志:一行一条 JSON,追加写入。删除写成明确的删除标记, 条目被彻底删除后连正文带历史一并抹掉,只留那一行。

操作日志即数据

每个账户一份 journal.log,一行一条 put / del。把日志从头重放一遍即为该账户的全部笔记与待办,不存在别的状态来源。

删除即删除

删除是一条带全局序号的删除标记。重放只会删除,不会把别的设备上的老副本推回来,也不会把已删除的条目重新生成。

可复用 ID

条目彻底删除后,其 ID 进回收池供后续新建使用;被某台设备领走的 ID 会被占住 15 分钟,条目落盘即释放,两台设备同时新建不会撞号。

幂等提交

每条操作带 opId。客户端断线重试、重复提交同一个 opId 不会重复写入日志,服务端返回它原来的序号。

令牌鉴权

每个账户一套访问令牌,服务端只保存 HMAC-SHA256 摘要,明文仅在创建或重置时显示一次,并可与设备 id 绑定。

路径安全

只接受数据目录内的相对路径,绝对路径与含 .. 的路径一律拒绝;每个凭据只能读写它所属账户的那一份日志。

多账户

服务端默认只有内置账户 admin,其余账户在面板中新建(名称 1~32 个字符、密码至少 8 位);各账户的笔记与待办互不可见。

管理后台

/admin 下的一套静态页:账户、令牌、日志与密码。页面按请求读取后原样返回,改动页面无需改动或重启服务端。

托管网页版客户端

启动时把 Web 仓库克隆到 web/ 并在根路径托管网页版;根路径留给客户端,同步接口在 /sync。

02同步模型

删除即删除,日志即数据

同步以「操作」为单位追加到日志,客户端只记录「已应用到第几号」, 同步过程即「拉取序号之后的操作并重放 + 推送本地改动」。

同步模型 客户端可见信息 删除的后果
文件快照 + 目录对比 远端存在 / 不存在某文件 「删除」与「从未见过」不可区分,其他设备会把本地缺失视为待上传,从而重新生成该文件
操作日志(本项目) 逐条 put / del 操作及其全局序号 删除是一条明确的删除标记,重放只会删除,不会反向产生写回

服务端做三件事

  1. 1

    受理

    逐条校验操作类型与路径,分配全局递增序号,以一行 JSON 追加写入并 fsync;同一 opId 重复提交直接返回原序号。

  2. 2

    拉取

    GET /sync/ops?since=&limit= 按序号分页返回,响应带上 latestSeq 与 hasMore;客户端据此记录进度。

  3. 3

    整理

    条目被彻底删除后抹掉其正文与历史,只留一行删除标记;其余操作的序号不动,各设备记的进度照旧有效。管理后台也可手动整理。

  • 一行一条:日志是 JSON Lines,追加写入;只有整理时才重写。
  • 序号不重排:整理只抹内容,不动序号,因此不会让任何设备重新拉取或漏拉。
  • 回收与占位:删除腾出的 ID 进回收池;被领走的占住 15 分钟,条目落盘即释放,超时自动回到池子。
  • 只发跟得上的:领取 ID 时带上自己的进度,只有序号已跟上的才发放;发起删除的那一台可以不等,本地早已删掉。

03数据与日志

数据目录即全部状态

账户表、各账户的日志与令牌都在 --data 指向的目录里。备份该目录即为一次完整备份。

目录结构

data/
data/
├── users.json          # 账户表:名称、口令摘要、启用与管理员标记
├── config.json         # 可选:host 与 port
└── users/
    ├── admin/
    │   ├── journal.log # 该账户的全部数据,一行一条 put / del
    │   ├── journal.id  # 日志身份,客户端据此判断是否仍是同一份日志
    │   └── tokens.json # 该账户的访问令牌,只存摘要
    └── alice/
        └── journal.log

日志里的一行

users/<账户 id>/journal.log
{"seq": 128, "opId": "5f0c…", "device": "台式机",
 "time": 1789840300633, "op": "put",
 "path": "notes/aB3dE5fG7h.md",
 "data": "<!--EsprinData … -->\n正文…",
 "encoding": "utf8", "hash": "sha256:…"}

日志身份

每个账户的日志目录下有一枚 journal.id,客户端据此判断「还是不是同一份日志」,避免把两份无关的数据接在一起。

口令与令牌

账户口令以 PBKDF2-SHA256(20 万次迭代)保存,令牌只存 HMAC-SHA256 摘要,两者都不落明文。

旧版布局迁移

旧版单账户布局(数据目录根下的 admin.json 与日志、令牌)在首次启动时自动并入内置账户,原文件归档为 admin.json.migrated。

04接口

三个入口,一套凭据

根路径 / 留给网页版客户端,管理后台在 /admin(接口在 /admin/api), 同步接口在 /sync。

接口 凭据 说明
GET /sync/health 不需要 探活,用于区分「地址错」与「凭据错」;带凭据时额外报出该账户的日志身份与账户名
GET /sync/ops?since=&limit= 需要 取该序号之后的操作,默认 500 条、最多 2000 条;响应带 latestSeq 与 hasMore
POST /sync/ops 需要 提交一批操作,逐条返回受理结果与序号;重复的 opId 返回原序号
GET /sync/state 需要 报出 latestSeq、操作总数、已删除条目数、回收池大小与日志身份
GET /sync/file?path= 需要 取单个条目的当前内容:正文、编码、哈希与它所在的序号
GET /sync/ids?limit= 需要 列出回收池中可复用的 ID,最近删除的排在前面
POST /sync/ids/claim 需要 领用可复用 ID(device / kind / count / since);序号没跟上的用 pending 报回
  • 凭据两种:客户端的访问令牌(Authorization: Bearer)或账户登录会话;/sync/* 未携带凭据一律返回 401。
  • 容量上限:单条操作的内容上限 8 MB,分页上限 2000 条;超出会逐条报错,不会中断整批。
  • 逐条回报:一批操作里合法的照常入日志,不合法的单独给出原因(未知操作类型 / 路径不合法 / 缺少内容 / 内容过大)。

05管理后台

账户、令牌与日志

启动后在浏览器中打开「服务器地址 + /admin」。管理页是仓库 manager/ 下的静态文件, 由服务端按请求读取后原样返回。

面板分节

管理后台 /admin
正在管理的账户
决定令牌与日志两节作用在哪个账户上;服务端可供多个账户使用,各自的笔记与待办互不可见。
账户
新建账户、启用与停用、管理员标记、改名与改密、删除(连同该账户的数据目录)。
新建令牌 / 已有令牌
名称与设备 id 可选;明文只在创建或重置的那一次显示,随时可复制。
数据与日志
日志概要(操作数、条目数、已删除数、回收池大小、体积与更新时间),可刷新、整理与下载。
账户密码
修改当前账户的密码,至少 8 位。

界面示意,按管理页的实际分节列出

  • 首次打开先设密码:全新部署的管理后台只有「设置管理密码」一页(至少 8 位),设完即以该账户进入。
  • 登录限流:同一来源 60 秒内连续失败 10 次即暂时拒绝;登录会话有效期 12 小时。
  • 内置账户不可动:admin 不可删除、不可停用、不可取消管理员,也不能改名(它的名字同时是数据目录名)。
  • 至少保留一个账户:删除到只剩一个时会被拒绝。
  • 下载即快照:下载得到的是当前账户那份 journal.log 的快照,这个账户的全部笔记与待办就在其中。

06部署

一个文件,一条命令

在存放数据的机器上运行即可。默认只监听本机;要给局域网内的设备使用, 把监听地址写成 0.0.0.0 并在防火墙上放行端口。

运行
# 默认:127.0.0.1:8686,数据目录 ./data
python sync.py
# 局域网共享,并指定数据目录
python sync.py --host 0.0.0.0 --port 8686 --data ./esprin-data
# 离线自测:覆盖接口、管理后台、账户、回收 ID、日志整理与旧版迁移,不监听端口
python sync.py --selftest
参数 说明 默认
--host 监听地址 127.0.0.1,或 config.json 中的 host
--port 监听端口 8686,或 config.json 中的 port
--data 数据目录:账户表与各账户的日志、令牌都在这里 ./data
--token 访问令牌,等同内置账户的一份令牌;不传则凭据只来自管理后台 环境变量 ESPRIN_TOKEN
--web-repo 网页版客户端仓库,web/ 里没有页面时从这里克隆 https://github.com/EsprinProject/Web.git
--web-ref 克隆该仓库的哪个分支或标签 main
--web-update 启动时对已有的克隆做一次 git pull --ff-only 关闭
--no-web-clone 不克隆网页版客户端,只用现有的 web/ 目录 关闭
--selftest 跑一遍内置自测后退出 关闭

配置文件

想让设置常驻而不改命令行,把 host 与 port 写进 config.json;查找顺序为数据目录、sync.py 所在目录、其下的 data/。命令行参数优先。

常驻运行

仓库带有 ecosystem.config.js,用 pm2 起名为 EsprinSync、以 python3 解释执行并自动重启;也可交给系统的服务管理器。

自测覆盖

--selftest 顺次跑过配置、接口、网页版、管理后台、账户、强制鉴权、重载日志、回收 ID、整理日志、重载后台与旧版迁移各节,全部离线完成。

07接入

客户端怎么连上来

桌面端与网页端共用同一套文件格式与同一份操作日志,同一批数据可在两端之间互相接管。

01

桌面客户端

在「设置 → 数据与存储 → 自建同步」中填写服务器地址与设备名,令牌经 IPC 单向提交、不回传明文,也不写进配置文件。

02

网页客户端

由本服务端在根路径托管:启动时把 Web 仓库克隆到 web/,打开服务器地址即为客户端,自动连接,必要时弹一次可关闭的登录卡片。

03

日志导入与摊平

客户端能把这份日志导入本地数据目录(以此为准还原数据),或把它摊成一个与数据目录同构的文件夹,便于直接查看与接管。