整理文档

This commit is contained in:
ChuNa7a committed 2026-10-16 18:09:11 +08:00
1 parent 06a3d8506b
commit 0e347aad9e
26 files changed
+1534 -503

No files matched your search

+1
View File
@@ -9,3 +9,4 @@ dist/
/deploy/secrets/ /deploy/secrets/
__pycache__/ __pycache__/
*.pyc *.pyc
external
Generated
+60 -20
View File
@@ -220,10 +220,10 @@ dependencies = [
"rand", "rand",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"ssh-key", "ssh-key",
"tempfile", "tempfile",
"tokio", "tokio",
"toml",
"tower", "tower",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -1245,6 +1245,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -1257,19 +1266,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -1484,6 +1480,47 @@ dependencies = [
"syn 3.0.9", "syn 3.0.9",
] ]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -1598,12 +1635,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@@ -1757,6 +1788,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.63" version = "0.8.63"
+14 -162
View File
@@ -1,169 +1,21 @@
# ChuBlog # ChuBlog
Rust/Axum 博客后端。文章作者在本地编辑 Markdown,通过受限 SSH Git 推送发布。注册、登录、权限管理使用 HTTP API;文章仅通过 Git 修改。ChuSQL 通过外部子模块 `external/ChuSQL-RustDriver` 接入,只存可重建索引。 Rust/Axum 博客后端。作者在独立 Git 仓库中编辑文本或 Markdown,通过受限 SSH Git 推送发布;`index.txt` 管理文章清单。HTTP API 提供阅读、账户与发布授权,ChuSQL 保存可重建索引。
本轮按最新需求只实现后端,没有前端或在线文章管理。`PLAN.md` 保留原始规划,当前范围与后续安排见 [TODO.md](TODO.md)。 当前仅提供后端,暂无前端和在线文章编辑。
## 项目与运行 ## 文档
```text | 文档 | 内容 |
ChuBlog/ | --- | --- |
├── apps/api/ | [开发与依赖](docs/development.md) | 获取代码、双 HTTPS 依赖源、开发检查与联调 |
├── external/ | [部署](docs/deployment.md) | Debian 13、Docker Compose、首次初始化 |
│ ├── ChuSQL/ # Git 子模块,主仓库只记录版本指针 | [文章发布](docs/publishing.md) | 账户授权、个人仓库、文章清单与 Git 工作流 |
│ └── ChuSQL-RustDriver/ # Git 子模块 | [API](docs/API.md) | HTTP 接口、认证、请求与错误响应 |
└── scripts/init-deps.py | [内容与隔离](docs/security.md) | 文件白名单、容量限制、SSH 与进程边界 |
``` | [运维与迁移](docs/operations.md) | 持久化、备份恢复、旧共享仓库迁移 |
| [架构决策](docs/decisions/README.md) | 已记录的设计选择及演进 |
项目提供两个 HTTPS 仓库,任选其一克隆: 源码:[GitHub](https://github.com/TheBadRoger/ChuBlog.git) · [自建仓库](https://repo.chunana.cn/ChuNa7a/ChuBlog.git)
```sh 规划:[当前范围与待办](TODO.md) · [原始规划](PLAN.md)
git clone https://github.com/TheBadRoger/ChuBlog.git
# 或:git clone https://repo.chunana.cn/ChuNa7a/ChuBlog.git
cd ChuBlog
python3 scripts/init-deps.py
```
数据库和驱动均提供两个 HTTPS 源:
| 依赖 | GitHub | 自建仓库 |
| --- | --- | --- |
| ChuSQL | https://github.com/TheBadRoger/ChuSQL.git | https://repo.chunana.cn/ChuNa7a/ChuSQL.git |
| ChuSQL-RustDriver | https://github.com/TheBadRoger/ChuSQL-RustDriver.git | https://repo.chunana.cn/ChuNa7a/ChuSQL-RustDriver.git |
初始化先尝试 GitHub HTTPS,连接、访问或固定提交获取失败时回退自建 HTTPS,无需 SSH 身份。已正确初始化的固定版本直接复用,有未提交修改时拒绝覆盖;回退不改为任意最新分支。项目不设置全局 `insteadOf`、localhost 映射、OS 用户或服务器 SSH 配置。
开发电脑仍可显式选择已有本地来源(仅开发覆盖参数,不是服务器默认流程);只读取已提交版本:
```sh
python3 scripts/init-deps.py --database-repo ../ChuSQL --driver-repo ../ChuSQLDriver
```
子模块固定到已提交版本;源仓库必须包含这些提交。依赖源码和构建产物不进入博客仓库,更新依赖时只提交 `.gitmodules`/配置及子模块指针。不要对主仓库使用 `git add --force` 重新收录依赖文件。
开发检查:
```sh
cargo test --all-targets
cargo fmt --check
cargo clippy --all-targets -- -D warnings
```
部署基于 Debian 13。`scripts/deploy.sh` 会使用同一个依赖初始化脚本,自动在上述两个 HTTPS 源之间回退,再构建外部数据库与 Docker 镜像;沿用运行者现有的 Git 配置,不在项目内覆盖服务器环境。
```sh
scripts/deploy.sh
```
首次部署先准备 `.env`;也可以手动执行以下同架构构建步骤:
```sh
# ChuSQL 尚未编译时,先按其项目说明构建 Rust 存储库、server 与 bootstrap。
python3 scripts/prepare-deploy.py --build-database
cp .env.example .env
chmod 600 .env
# 从标准输入输入管理员口令,Ctrl-D 结束;不要把明文写进命令行历史。
./deploy/bin/chublog-api hash-password
# 将输出以单引号包裹写入 .env 的 ADMIN_PASSWORD_HASH;同时配置域名及两份不同的随机 DB 口令。
docker compose --env-file .env -f deploy/compose.yaml up -d --build
# db 启动就绪后,创建 chublog 库和非超级用户 chublog_index;失败时检查日志后重试。
docker compose --env-file .env -f deploy/compose.yaml run --rm db-init
```
部署入口启动服务后,首次仍需执行上面的 `db-init` 命令;入口不会在数据库启动竞态中静默吞掉初始化错误。
构建时若 Debian 官方下载源较慢,可在 `.env` 配置 `DEBIAN_MIRROR=mirrors.ustc.edu.cn`,仍使用 Debian 签名校验。
`prepare-deploy.py` 默认从数据库子模块读取编译产物;`--build-database` 会先构建其 Rust 存储库、Haskell server 和 bootstrap。也可用 `--chusql-source /已有构建的/ChuSQL` 选择已有外部构建,没有产物会明确失败。编译产物只暂存在被忽略的 `deploy/bin/`。跨平台部署须先在目标 Debian/架构构建,不能复制 macOS、Windows 或其他架构二进制。镜像不含编译器、源码、`.env` 或密码文件。首次启动从环境中的哈希创建管理员,后续账号与权限以持久文件为准,修改初始哈希不会覆盖已有账户。
Caddy 根据 `SITE_ADDRESS` 提供 HTTPS 和 API 反向代理;`PUBLIC_ORIGIN` 必须与外部站点完全一致,不带结尾斜杠。HTTP 仅对 localhost 开发场景允许。只需公网 TCP 80/443 和 Git SSH 2222(可改);主机管理 SSH 另行管理,数据库和 API 不映射宿主端口。既有 UDP 7001 不受影响。参考 [Caddy 请求体限制](https://caddyserver.com/docs/caddyfile/directives/request_body) 与 [反向代理文档](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)。
## 用户与发布授权
完整接口见 [docs/API.md](docs/API.md)。
1. 注册:`POST /api/auth/register`,正文 `{ "username": "alice", "password": "至少12字节的口令" }`。默认角色 `reader`,绝不接受客户端指定角色。`ALLOW_REGISTRATION=false` 可关闭注册。
2. 登录:`POST /api/auth/login`,相同字段。浏览器保存 HttpOnly Cookie;返回 `csrf`。所有修改请求必须携带站点 `Origin` 和 `X-CSRF-Token`。
3. 本地生成 `ssh-keygen -t ed25519` 或 `ssh-keygen -t rsa -b 3072`;登录后 `PUT /api/account/ssh-key` 提交 `{ "public_key": "ssh-ed25519 AAAA..." }`,只交公钥。RSA 使用标准 `ssh-rsa AAAA...` 公钥文本,支持 2048–8192 位;实际认证使用 RSA/SHA-256 或 RSA/SHA-512,不启用旧的 SHA-1 签名。见 [OpenSSH 说明](https://www.openssh.com/txt/release-8.8)。
4. 管理员登录后调用 `PATCH /api/admin/users/alice`,正文 `{ "enabled": true, "role": "publisher", "prefixes": ["alice-"] }`。不同有效发布者的前缀不能重叠。
5. 此时 SSH 才接受该用户的密钥;`reader`、停用用户、未绑密钥的用户均不能访问 Git。管理员也需显式分配前缀和公钥才能发布。
授权与密钥独立:知道服务器地址、有网站账户、上传了公钥都不足以推送。设回 `reader` 或 `enabled:false` 会撤回发布授权,并使现有网站会话失效。禁止管理员停用自己或取消自己的管理角色。
## 本地 Git 工作流
先通过可信渠道核对服务器 Ed25519 主机密钥指纹;不要禁用主机密钥检查。主机密钥保存在独立持久卷,重建容器不会更换。所有发布者使用同一个 OS 账户 `git`,身份由对应公钥的强制命令绑定,不由 Git author/email 决定。
```sh
# ~/.ssh/config
Host chublog
HostName blog.example.com
Port 2222
User git
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
# 首次发布,远端是空仓库时:
git init --initial-branch=main my-articles
cd my-articles
git remote add origin chublog:content.git
# 创建 posts/alice-first/index.md,规范参考 content/posts/welcome/index.md。
# 样例 slug welcome 需要改成自己获授权的 alice-first。
git add posts/alice-first
git commit -m 'Publish first article'
git push -u origin main
# 已有内容时先 clone;每轮编辑前拉取最新内容。
git clone chublog:content.git
cd content
git pull --ff-only
# 编辑 posts/alice-first/index.md
git add posts/alice-first
git commit -m 'Update article'
git push
```
MVP 每次只接受 **一个直接继承远端 main 的提交**,首次仅允许一个根提交。禁止 merge、force push、删除 main、标签及其他分支;本地有多次提交时先整理为单次提交,再拉取/变基到最新远端。发生并发冲突时,拉取并解决后重试。
`status: published` 发布,改为 `draft` 撤回,删除文章目录则删除当前版本。图片置于同一目录,Markdown 使用 `![说明](cover.png)`,不允许外链图片。API 在同步后读取新内容,一般约 2 秒;索引重建期间可能延迟到约 32 秒。`GET /api/admin/status` 查看已导入与已索引 commit;Git 接受只表示接收校验完成,尚不代表索引同步成功。
**授权发布者可以 clone 全部文章仓库及历史,包括其他发布者的草稿。** 草稿只对公开 HTTP 隔离,当前仓库不提供发布者之间的草稿保密;不要提交秘密或私钥。删除/撤回不能抹掉 Git 历史。
## 内容与隔离边界
- 唯一内容入口是 `posts/<slug>/index.md` 和同目录 PNG/JPEG/WebP;禁止额外文件、目录、符号链接、可执行位、子模块和 LFS 指针。
- Markdown 上限 256 KiB;图片单张 2 MiB、最多 12 张、尺寸 4096×4096;文章合计 6 MiB;快照原始文件合计 64 MiB、最多 1000 篇。
- Git 输入 pack 上限 8 MiB;接收仓库达到 256 MiB 后拒绝新 SSH 会话,需要管理员离线维护。此阈值是应用检查,生产还应为 Docker 数据盘配置真实文件系统配额。
- 使用成熟 Markdown 解析器,拒绝原始 HTML、危险链接协议、外链图片、控制符和双向隐藏字符;代码块是惰性文本,绝不执行 MDX、JS、仓库脚本、构建任务或上传内容。
- 支持 Ed25519 和 2048–8192 位 RSA 公钥,拒绝弱密钥、负数/畸形 RSA 参数及额外二进制数据;拒绝把 `authorized_keys` 选项当作公钥上传。SSH 只接受精确的 `git-receive-pack 'content.git'` / `git-upload-pack 'content.git'`,没有 shell、SCP/SFTP、TTY、转发、隧道、用户环境变量。
- 接收仓库由 UID 10002 管理;API 为独立 UID 10001,正式仓库/用户密码文件不挂载给 SSH 容器;SSH 只读授权文件和只读固定 hook。接收内容再次验证后才导入 API 自己的 Git 仓库。
- HTTP 请求体 8 KiB;注册/登录合计全局 10 次/分钟,修改操作全局 60 次/分钟;密码哈希并发最多 2。1000 用户、1000 个有效会话上限,一小时固定会话期限。全局限制偏保守,后续可细化 IP/账户限制以改善公平性。
- 统计不收集 IP/访客身份:公开文章数、标签数、Markdown 字节数、启动以来文章 API 请求数。请求数不是独立访客数,重启后清零。
服务器只返回 JSON Markdown 与图片;后续前端必须把元数据当文本输出,并继续禁用原始 HTML、MDX和危险链接,不能将接口字段直接作为 HTML 执行。隔离不能替代 Git/OpenSSH/解码库的持续安全更新。
## 持久化、故障和恢复
`content` 卷包含正式 `content.git`、`users.json` 和公开 `published.json`;`authorization` 卷是独立发布授权与公钥;`ingress` 是 SSH 接收仓库;`ssh-host-keys` 保留服务器身份。数据库和数据库日志为独立卷。日志输出 stdout,由 Docker 分卷轮换,不记录口令、会话或私钥。
文章索引失败不会修改 Git 或阻塞 HTTP 阅读,后台约每分钟重试并检查索引是否丢失。恢复数据库后可自动重建;离线命令为 `chublog-api rebuild-index`(必须停止同一数据卷上的 API 进程后运行,防止并发),或等待在线自动重试。`indexed_commit:null` 表示尚无已确认可用的索引,旧值表示尚未追平当前内容。应用不依赖 ChuSQL 事务。
备份必须同时包含正式 Git、用户文件、授权卷、SSH 主机密钥;备份包含密码哈希,须加密并限制权限。接收 Git 可额外镜像备份。恢复时先停止写入,从同一备份恢复各卷,再启动;不要只恢复 ChuSQL,也不要直接向正式仓库推送。应用启动从 Git 重新校验并生成快照。进程重启会注销所有会话。
本实现对文件写入执行原子替换与 fsync,Git 引用采用受控更新;磁盘故障、主机断电和备份恢复仍需真实环境演练。不要对含正式数据的项目执行 `docker compose down -v`。
## 联调
```sh
python3 scripts/prepare-deploy.py --build-database
python3 scripts/smoke.py
python3 scripts/smoke.py --key-type rsa
```
联调使用独立随机命名的 Compose 项目、本机 18080/18443/12222 端口,测试结束清理自己创建的容器与卷。覆盖真实 SSH 身份与授权、非法推送、发布撤回、ChuSQL 索引、断库阅读、重启与撤权。该测试会拉取基础镜像,需要 Docker 可用。
## 历史清理后的协作
驱动的旧源码副本已从主仓库可达历史移除,后续只保留外部子模块配置。历史重写后,已有克隆建议重新克隆并初始化依赖;不要把旧 main 合并回来,否则可能重新引入已删除的依赖历史。自建服务器的不可达对象清理与其他人的旧克隆需要分别处理,GitHub 缓存和旧提交直链的回收由平台管理。
+54 -9
View File
@@ -1,6 +1,6 @@
# ChuBlog — 后续待办计划 # ChuBlog — 后续待办计划
更新:2026-10-15。本文依据最新需求划分后续工作;`PLAN.md` 保留原始总体规划。当前只做后端,Git 是唯一文章写入入口。 更新:2026-10-16。本文依据最新需求划分后续工作;`PLAN.md` 保留原始总体规划。当前只做后端,Git 是唯一文章写入入口。以下未勾选项均为待办,不代表已经实现。
## 当前已实现 ## 当前已实现
@@ -9,11 +9,11 @@
- [x] reader / publisher / admin 权限;注册不能自选权限,管理员单独审批发布权、停用用户,修改权限使已有会话失效。 - [x] reader / publisher / admin 权限;注册不能自选权限,管理员单独审批发布权、停用用户,修改权限使已有会话失效。
- [x] 用户上传/轮换自己的 Ed25519/RSA 公钥,公钥存在与具有发布权分别检查。 - [x] 用户上传/轮换自己的 Ed25519/RSA 公钥,公钥存在与具有发布权分别检查。
- [x] OpenSSH 强制命令:仅固定仓库的 Git push/clone;禁用 shell、SFTP/SCP、TTY、代理/端口转发、隧道与用户环境。 - [x] OpenSSH 强制命令:仅固定仓库的 Git push/clone;禁用 shell、SFTP/SCP、TTY、代理/端口转发、隧道与用户环境。
- [x] 发布者 slug 前缀授权,不允许重叠授权或修改其他命名空间。 - [x] 每位作者独立 SSH 文章仓库与 Git 对象历史,公钥身份映射固定仓库别名,无法读取或改写他人仓库。
- [x] pre-receive 校验 Git quarantine 内的候选提交:单次一个线性提交、仅 main、禁止删除分支、强推、标签及其他分支。 - [x] pre-receive 校验 Git quarantine 内的候选提交:单次一个线性提交、仅 main、禁止删除分支、强推、标签及其他分支。
- [x] Markdown Frontmatter、路径、文件模式、大小配额、图片类型/内容/尺寸与链接安全校验;拒绝原始 HTML、隐藏控制符、符号链接、可执行文件及额外文件。 - [x] Markdown Frontmatter、路径、文件模式、大小配额、图片类型/内容/尺寸与链接安全校验;拒绝原始 HTML、隐藏控制符、符号链接、可执行文件及额外文件。
- [x] 独立 SSH 接收仓库与 API 正式仓库;从 Git 恢复公开快照,草稿不出现在公开 API/资源接口。 - [x] 按作者隔离 SSH 接收仓库与 API 私有 Git 缓存;三列 `index.txt` 登记文章 ID、目录和正文文件名,增删条目自动同步阅读快照和 ChuSQL 元信息;未登记文件不导入,草稿不出现在公开接口。
- [x] 外部 `ChuSQL`/`ChuSQL-RustDriver` 子模块配置、GitHub HTTPS 默认来源及授权设备 SSH 优先/HTTPS 回退;ChuSQL 公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。 - [x] 外部 `ChuSQL`/`ChuSQL-RustDriver` 子模块配置、GitHub HTTPS 默认来源及授权设备 SSH 优先/HTTPS 回退;ChuSQL 文章登记元信息、公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。
- [x] Docker Compose、Caddy 反向代理、独立持久卷、容器资源限制、最小进程身份与网络隔离。 - [x] Docker Compose、Caddy 反向代理、独立持久卷、容器资源限制、最小进程身份与网络隔离。
- [x] 后端/驱动自动测试、真实容器 SSH 联调脚本、运行/API/架构说明。 - [x] 后端/驱动自动测试、真实容器 SSH 联调脚本、运行/API/架构说明。
@@ -24,20 +24,65 @@
- [ ] 给 Docker 数据目录设置文件系统硬配额;用压缩炸弹、大量失败推送和连接洪泛验证空间、CPU、内存、超时及进程数边界。 - [ ] 给 Docker 数据目录设置文件系统硬配额;用压缩炸弹、大量失败推送和连接洪泛验证空间、CPU、内存、超时及进程数边界。
- [ ] 对正式 Git、用户哈希、授权文件、SSH 主机密钥制定加密备份与保留策略,执行完整恢复演练。 - [ ] 对正式 Git、用户哈希、授权文件、SSH 主机密钥制定加密备份与保留策略,执行完整恢复演练。
- [ ] 验证断电/磁盘满时 Git 引用、快照、账户及权限文件的一致性;故障恢复后不保留过期发布权。 - [ ] 验证断电/磁盘满时 Git 引用、快照、账户及权限文件的一致性;故障恢复后不保留过期发布权。
- [ ] 在实际 ChuSQL 版本执行长期并发、进程崩溃恢复及索引故障注入验证;保持其派生索引定位。 - [ ] 在实际 ChuSQL 版本执行长期并发、进程崩溃恢复及索引故障注入验证;文章索引保持可重建,后续账户、评论及互动数据作为独立持久数据备份恢复。
- [ ] 固定经过验证的镜像 digest,建立 Git/OpenSSH/Rust 依赖和基础系统安全更新流程。 - [ ] 固定经过验证的镜像 digest,建立 Git/OpenSSH/Rust 依赖和基础系统安全更新流程。
验收:有完整恢复记录;恶意推送不能影响正式内容;撤权后不能再次推送;数据库宕机仍可阅读,恢复后索引 commit 与内容一致。 验收:有完整恢复记录;恶意推送不能影响正式内容;撤权后不能再次推送;数据库宕机仍可阅读公开文章,恢复后索引 commit 与内容一致。新增数据库权限系统后,身份或权限无法确认时拒绝受保护访问,不得降级为公开访问。
## 第二优先级:账户与权限完善 ## 第二优先级:账户与权限完善
### 数据库用户系统与角色
- [ ] 将用户、密码哈希、角色及发布授权迁入 ChuSQL;当前文件账户和 reader / publisher / admin 角色需提供迁移方案。文章正文仍由 Git 管理,账户数据不能作为可丢弃的派生索引。
- [ ] 保持密码使用 Argon2id 等慢哈希及每个密码独立的随机盐,保存算法与参数,支持参数升级;禁止保存明文密码或使用普通快速摘要代替密码哈希。
- [ ] 预留可扩展权限属性,评估 ChuSQL 自定义 ENUM 类型及驱动兼容性,落实以下角色与继承关系:
| 角色 | 权限 |
| --- | --- |
| `BANNED` | 被封禁,无法登录;已有会话和 SSH 发布授权同步失效。 |
| `MEMBER` | 正常用户,可以阅读权限范围内的文章、点赞、收藏和评论。 |
| `AUTHOR` | 具有 MEMBER 权限,并可发布、管理自己的文章和改变自己文章的可见性。 |
| `MASTER` | 具有 AUTHOR 权限,并可改变他人文章可见性、设置或取消精华、删除他人文章、将他人文章设为内部文章,以及给他人赋权。 |
- [ ] 所有角色与文章所有权检查在服务端执行;注册不能自选高权限,撤权立即影响会话与 Git 发布。SSH 公钥身份验证与站内发布授权继续分别检查。
- [ ] 明确 Git 内容与数据库中站长管理状态的优先级,防止作者再次推送覆盖站长的隐藏、内部化、删除或精华决定;记录权限及管理操作审计。
### 内部文章与查阅口令
- [ ] 支持公开文章与内部文章;内部文章默认要求登录,草稿继续按独立发布状态处理。
- [ ] 部分内部文章可配置查阅口令,游客验证口令后可访问对应文章;口令以加盐慢哈希保存,限制尝试频率,授权限定文章及有效期,口令变更后可撤销授权。
- [ ] 作者可调整自己文章可见性,MASTER 可调整他人文章;口令和访问凭据不得以明文写入 Git 或日志。
- [ ] 正文、图片、附件、列表摘要、搜索、RSS、缓存和统计接口统一执行访问控制,避免直链或缓存泄露内部内容。明确 Git 作者可读取仓库历史的边界,不能仅靠阅读 API 权限保证作者之间保密。
### 评论与存储限制
- [ ] 评论内容及其作者关联、文章关联、时间和管理状态保存于 ChuSQL,支持分页读取和必要索引。
- [ ] 明确文章及评论的字数上限、UTF-8 字节上限、请求体上限和累计存储配额,并在服务端写入前强制验证;现有文章大小限制继续生效。
- [ ] 支持已登录用户与游客评论;落实匿名身份、限流、防刷、重复提交处理及评论管理规则,评论输出转义或清理,防止脚本注入。
### 互动统计、热度与精华
- [ ] 支持任何人(包括游客)点赞、收藏、评论;设计匿名身份和去重规则,并与 MEMBER 的账户关联互动统一,防止重复刷量。
- [ ] 持久化每篇文章的浏览量、点赞数、收藏数、评论数;明确浏览计数、取消点赞/收藏、评论删除及并发更新的一致性口径。
- [ ] 提供汇总统计和分页文章列表,支持按点赞数等指标降序排列;只返回请求者可见的文章及相应统计。
- [ ] 设计综合浏览、点赞、收藏、评论及发布时间的热度算法,明确权重、时间衰减、防刷和重算策略,用于优化首页推荐。
- [ ] 增加文章精华状态,由 MASTER 设置或取消;首页推荐优先展示精华,再结合热度排序,始终遵守文章可见性限制。
- [ ] 为互动数据与统计制定存储预算、聚合及保留策略;现有内存请求计数重启清零,不能作为持久浏览量使用。
### 后期 HTML 富文本
- [ ] 后期评估 HTML 富文本支持;启用前设计允许的标签、属性和链接协议,清理危险 HTML、事件属性及嵌入内容,补充 XSS 验证与 CSP。当前继续拒绝原始 HTML/MDX,不能执行作者提交的脚本。
### 账户安全与发布授权补强
- [ ] 管理员 MFA(成熟身份代理或 TOTP),敏感授权操作二次验证。 - [ ] 管理员 MFA(成熟身份代理或 TOTP),敏感授权操作二次验证。
- [ ] 安全的管理员离线恢复/重置流程;用户密码找回需要先决定可信邮件或邀请渠道,禁止不验证身份的重置接口。 - [ ] 安全的管理员离线恢复/重置流程;用户密码找回需要先决定可信邮件或邀请渠道,禁止不验证身份的重置接口。
- [ ] 每用户多把公钥、指纹展示、单钥撤销、最后使用时间;保留每钥身份绑定。 - [ ] 每用户多把公钥、指纹展示、单钥撤销、最后使用时间;保留每钥身份绑定。
- [ ] 在全局限流之上增加可信代理来源的 IP 与账户维度限制、失败退避,减轻公共认证桶被恶意占满的问题。 - [ ] 在全局限流之上增加可信代理来源的 IP 与账户维度限制、失败退避,减轻公共认证桶被恶意占满的问题。
- [ ] 注册开放策略、邀请注册或邮件验证;保留注册与发布审批的权限分离。 - [ ] 注册开放策略、邀请注册或邮件验证;保留注册与发布审批的权限分离。
- [ ] 持久化结构化审计,明确记录每次接受的 commit 对应站内用户与公钥指纹,并设置保留周期。当前 SSH 接收日志已有身份与 commit,API 导入日志只有 commit,尚无统一审计查询。 - [ ] 持久化结构化审计,明确记录每次接受的 commit 对应站内用户与公钥指纹,并设置保留周期。当前 SSH 接收日志已有身份与 commit,API 导入日志只有 commit,尚无统一审计查询。
- [ ] 发布者之间的草稿隔离需求确认:当前授权作者能 clone 全仓库及历史。如需作者之间保密,改用独立收稿仓库,再统一发布。 - [ ] 为旧共享仓库迁移执行所有权核对、只导出最新文件的个人仓库重建及链接重定向;不得向作者分发旧共享历史。
- [ ] 在索引导入与公开发布之间加入站长审核:新增文章及后续修改先成为待审核版本,MASTER 决定通过或驳回;未通过内容不得公开,修改审核期间保留上一已通过版本,明确索引删除/撤回语义、审计与作者状态查询。本轮只保留该待办,不启用审核。
## 第三优先级:Astro + TypeScript 阅读前端 ## 第三优先级:Astro + TypeScript 阅读前端
@@ -56,9 +101,9 @@
- [ ] 目录配额预警、安全 GC 与维护窗口;禁止在 SSH 请求里运行任意维护命令。 - [ ] 目录配额预警、安全 GC 与维护窗口;禁止在 SSH 请求里运行任意维护命令。
- [ ] 扩充现有 commit/行数对账为完整字段与标签对账;增加磁盘/登录失败/索引延迟/导入失败告警,以及受控恢复工具。 - [ ] 扩充现有 commit/行数对账为完整字段与标签对账;增加磁盘/登录失败/索引延迟/导入失败告警,以及受控恢复工具。
- [ ] API 分页与条件缓存:保留撤回后不可继续公开访问的语义。 - [ ] API 分页与条件缓存:保留撤回后不可继续公开访问的语义。
- [ ] 更完整的统计:先定义访问口径与隐私策略,再决定是否持久化每日聚合;当前请求计数不是独立访客数,重启清零。 - [ ] 配合第二优先级的互动统计,落实访问口径、隐私策略、每日聚合和统计故障恢复。
- [ ] 外部资源链接检查、slug 重定向与图片元数据清理;不能因此在服务器执行作者提供的命令。 - [ ] 外部资源链接检查、slug 重定向与图片元数据清理;不能因此在服务器执行作者提供的命令。
## 后续再评估 ## 后续再评估
在线编辑/上传/预览、全文检索、评论、多副本和更复杂的角色。若引入在线编辑,应先明确其如何经过同一授权、校验与发布协调流程,不能在现有 Git 仓库上新增未经协调的第二个写入口。 在线编辑/上传/预览、全文检索和多副本。评论、四级角色和精华功能已列入明确待办;HTML 富文本按上述后期计划评估。若引入在线编辑,应先明确其如何经过同一授权、校验与发布协调流程,不能在现有 Git 仓库上新增未经协调的第二个写入口。
+1 -1
View File
@@ -15,7 +15,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
rand = "0.8" rand = "0.8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9" toml = "0.8.23"
ssh-key = { version = "0.6.7", default-features = false, features = ["std"] } ssh-key = { version = "0.6.7", default-features = false, features = ["std"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
pulldown-cmark = "0.13" pulldown-cmark = "0.13"
+2 -19
View File
@@ -34,6 +34,7 @@ pub enum Role {
pub struct Grant { pub struct Grant {
pub enabled: bool, pub enabled: bool,
pub role: Role, pub role: Role,
#[serde(default)]
pub prefixes: Vec<String>, pub prefixes: Vec<String>,
pub ssh_key: Option<String>, pub ssh_key: Option<String>,
} }
@@ -507,6 +508,7 @@ pub async fn users(State(app): State<Arc<App>>, headers: HeaderMap) -> ApiResult
pub struct GrantInput { pub struct GrantInput {
enabled: bool, enabled: bool,
role: Role, role: Role,
#[serde(default)]
prefixes: Vec<String>, prefixes: Vec<String>,
} }
pub async fn grant( pub async fn grant(
@@ -517,9 +519,6 @@ pub async fn grant(
) -> ApiResult<Json<Value>> { ) -> ApiResult<Json<Value>> {
let actor = authorize(&app, &headers, true, true).await?; let actor = authorize(&app, &headers, true, true).await?;
ensure_prefixes(&input.prefixes).map_err(bad)?; ensure_prefixes(&input.prefixes).map_err(bad)?;
if input.role == Role::Publisher && input.prefixes.is_empty() {
return Err(bad("发布者必须分配文章前缀"));
}
let mut accounts = app.accounts.lock().await; let mut accounts = app.accounts.lock().await;
if !accounts if !accounts
.grants .grants
@@ -535,22 +534,6 @@ pub async fn grant(
if actor == name && (input.role != Role::Admin || !input.enabled) { if actor == name && (input.role != Role::Admin || !input.enabled) {
return Err(bad("不能取消自己的管理权限或停用自己")); return Err(bad("不能取消自己的管理权限或停用自己"));
} }
for (other, grant) in &accounts.grants.publishers {
if other == &name || !grant.enabled || grant.role == Role::Reader {
continue;
}
if input.enabled
&& input.role != Role::Reader
&& input.prefixes.iter().any(|a| {
grant
.prefixes
.iter()
.any(|b| a.starts_with(b) || b.starts_with(a))
})
{
return Err(bad("发布前缀与其他用户重叠"));
}
}
let mut grants = accounts.grants.clone(); let mut grants = accounts.grants.clone();
let entry = grants let entry = grants
.publishers .publishers
+378 -82
View File
@@ -1,6 +1,5 @@
use anyhow::{Context, Result, ensure}; use anyhow::{Context, Result, ensure};
use base64::{Engine, engine::general_purpose::STANDARD}; use base64::{Engine, engine::general_purpose::STANDARD};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
collections::BTreeMap, collections::BTreeMap,
@@ -23,10 +22,15 @@ pub enum Status {
Published, Published,
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Metadata { pub struct Metadata {
pub title: String, pub title: String,
#[serde(default)]
pub cover: Option<String>,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub slug: String, pub slug: String,
pub date: String, pub date: String,
pub updated: String, pub updated: String,
@@ -35,24 +39,51 @@ pub struct Metadata {
pub description: String, pub description: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Asset { pub struct Asset {
pub name: String, pub name: String,
pub data: String, pub data: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct Post { pub struct Post {
pub meta: Metadata, pub meta: Metadata,
pub markdown: String, pub markdown: String,
#[serde(default)]
pub entry: String,
#[serde(default)]
pub pages: BTreeMap<String, String>,
pub assets: Vec<Asset>, pub assets: Vec<Asset>,
#[serde(default)]
pub author: String,
#[serde(default)]
pub article_id: String,
#[serde(default)]
pub path: String,
#[serde(default)]
pub git_commit: String,
#[serde(default = "markdown_format")]
pub format: String,
}
fn markdown_format() -> String {
"markdown".into()
} }
#[derive(Clone, Default, Deserialize, Serialize)] #[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct Snapshot { pub struct Snapshot {
pub commit: String, pub commit: String,
pub posts: BTreeMap<String, Post>, pub posts: BTreeMap<String, Post>,
#[serde(default)]
pub revisions: BTreeMap<String, String>,
#[serde(default)]
pub pending: BTreeMap<String, PendingIndex>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct PendingIndex {
pub commit: String,
pub reason: String,
} }
pub fn slug(value: &str) -> Result<&str> { pub fn slug(value: &str) -> Result<&str> {
@@ -75,51 +106,38 @@ pub fn slug(value: &str) -> Result<&str> {
Ok(value) Ok(value)
} }
pub fn parse(markdown: String, assets: Vec<Asset>) -> Result<Post> { pub fn stamp(post: &mut Post, previous: Option<&Post>, now: &str) {
ensure!(markdown.len() <= MAX_MARKDOWN, "Markdown 超过 256 KiB"); post.meta.created_at = previous
let normalized = markdown.replace("\r\n", "\n"); .map(|p| p.meta.created_at.clone())
let rest = normalized .filter(|s| !s.is_empty())
.strip_prefix("---\n") .unwrap_or_else(|| now.into());
.context("缺少 YAML Frontmatter")?; post.meta.date = previous
let (yaml, _) = rest.split_once("\n---\n").context("Frontmatter 未闭合")?; .map(|p| p.meta.date.clone())
let meta: Metadata = serde_yaml::from_str(yaml).context("Frontmatter 无效")?; .filter(|s| !s.is_empty())
slug(&meta.slug)?; .unwrap_or_else(|| {
ensure!( if post.meta.status == Status::Published {
!meta.title.trim().is_empty() && meta.title.len() <= 240 && meta.description.len() <= 1000, now.into()
"标题或摘要长度无效" } else {
); String::new()
for date in [&meta.date, &meta.updated] {
let parsed =
NaiveDate::parse_from_str(date, "%Y-%m-%d").context("日期必须为 YYYY-MM-DD")?;
ensure!(
parsed.format("%Y-%m-%d").to_string() == *date,
"日期格式不规范"
);
} }
ensure!(meta.updated >= meta.date, "更新时间不能早于发布日期"); });
ensure!( let unchanged = previous.is_some_and(|p| {
meta.tags.len() <= 12 p.meta.title == post.meta.title
&& meta && p.meta.description == post.meta.description
.tags && p.meta.tags == post.meta.tags
.iter() && p.meta.cover == post.meta.cover
.all(|t| !t.trim().is_empty() && t.len() <= 80), && p.meta.status == post.meta.status
"标签无效" && p.path == post.path
); && p.pages == post.pages
ensure!(assets.len() <= 12, "最多 12 张图片"); && p.assets == post.assets
let mut names = std::collections::HashSet::new(); && p.markdown == post.markdown
let mut total = normalized.len(); && p.entry == post.entry
for asset in &assets { });
ensure!(names.insert(&asset.name), "图片名称重复"); post.meta.updated = if unchanged {
let bytes = decode_asset(asset)?; previous.unwrap().meta.updated.clone()
total += bytes.len(); } else {
} now.into()
ensure!(total <= 6 * 1024 * 1024, "文章及图片总计不得超过 6 MiB"); };
validate_markdown(rest.split_once("\n---\n").unwrap().1, &assets)?;
Ok(Post {
meta,
markdown: normalized,
assets,
})
} }
pub fn decode_asset(asset: &Asset) -> Result<Vec<u8>> { pub fn decode_asset(asset: &Asset) -> Result<Vec<u8>> {
@@ -131,7 +149,7 @@ pub fn decode_asset(asset: &Asset) -> Result<Vec<u8>> {
&& name && name
.bytes() .bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)) .all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
&& name != "index.md", && !matches!(name.as_str(), "index.md" | "index.markdown" | "index.txt"),
"图片文件名无效" "图片文件名无效"
); );
ensure!(asset.data.len() <= 3 * 1024 * 1024, "图片超过大小限制"); ensure!(asset.data.len() <= 3 * 1024 * 1024, "图片超过大小限制");
@@ -261,19 +279,139 @@ pub async fn init(data: &Path) -> Result<PathBuf> {
Ok(repo) Ok(repo)
} }
pub async fn scan(repo: &Path) -> Result<Snapshot> { #[derive(Debug)]
let commit = String::from_utf8(git(repo, &["rev-parse", "refs/heads/main"], None).await?)? pub enum Scan {
.trim() Ready(Snapshot),
.to_owned(); Pending(String),
scan_at(repo, &commit).await }
impl Scan {
pub fn ready(self) -> Result<Snapshot> {
match self {
Self::Ready(snapshot) => Ok(snapshot),
Self::Pending(reason) => anyhow::bail!("索引待修复:{reason}"),
}
}
} }
pub async fn scan_at(repo: &Path, commit: &str) -> Result<Snapshot> { #[derive(Debug, Deserialize)]
let commit = commit.to_owned(); #[serde(deny_unknown_fields)]
let entries = git(repo, &["ls-tree", "-rz", &commit], None).await?; struct Manifest {
let mut grouped: BTreeMap<String, (Option<String>, Vec<Asset>)> = BTreeMap::new(); #[serde(default)]
articles: Vec<ManifestEntry>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestEntry {
pub id: String,
pub folder: String,
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub cover: Option<String>,
#[serde(default, rename = "public")]
pub is_public: bool,
#[serde(default = "default_entry")]
pub entry: String,
}
fn default_entry() -> String {
"main.md".into()
}
pub fn manifest(text: &str) -> Result<Vec<ManifestEntry>> {
ensure!(text.len() <= 65536, "index.toml 超过 64 KiB");
validate_text(text)?;
let index: Manifest = toml::from_str(text).context("index.toml 格式或字段无效")?;
ensure!(index.articles.len() <= 1000, "最多 1000 篇文章");
let mut ids = std::collections::BTreeSet::new();
let mut folders = std::collections::BTreeSet::new();
for article in &index.articles {
slug(&article.id)?;
ensure!(
article.id.len() <= 40 && ids.insert(&article.id),
"文章 ID 过长或重复"
);
slug(
article
.folder
.strip_prefix("posts/")
.context("文章目录必须位于 posts 下")?,
)?;
ensure!(folders.insert(&article.folder), "文章目录不能重复映射");
filename(&article.entry)?;
ensure!(
matches!(
article.entry.rsplit('.').next(),
Some("md" | "markdown" | "txt")
),
"入口必须为 md、markdown 或 txt"
);
ensure!(
!article.title.trim().is_empty()
&& article.title.len() <= 240
&& article.description.len() <= 1000,
"标题或简介长度无效"
);
ensure!(
article.tags.len() <= 12
&& article
.tags
.iter()
.all(|t| !t.trim().is_empty() && t.len() <= 80),
"标签无效"
);
if let Some(cover) = &article.cover {
filename(cover)?;
}
}
Ok(index.articles)
}
fn filename(name: &str) -> Result<()> {
ensure!(
!name.is_empty()
&& name.len() <= 100
&& !name.starts_with('.')
&& !name.contains("..")
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)),
"文件名无效"
);
Ok(())
}
pub fn validate_text(text: &str) -> Result<()> {
ensure!(
!text.contains("-----BEGIN ") && !text.contains("-----END "),
"禁止提交 PEM 证书或密钥"
);
ensure!(
!text.chars().any(|c| c == '\0'
|| (c.is_control() && !"\n\r\t".contains(c))
|| matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')),
"禁止控制符及双向隐藏字符"
);
Ok(())
}
#[derive(Default)]
struct Folder {
texts: BTreeMap<String, String>,
assets: Vec<Asset>,
bytes: usize,
}
pub async fn scan_at(repo: &Path, commit: &str) -> Result<Scan> {
let entries = git(repo, &["ls-tree", "-rz", commit], None).await?;
let mut grouped: BTreeMap<String, Folder> = BTreeMap::new();
let mut index = None;
let mut total = 0; let mut total = 0;
let mut count = 0;
for entry in entries.split(|b| *b == 0).filter(|e| !e.is_empty()) { for entry in entries.split(|b| *b == 0).filter(|e| !e.is_empty()) {
count += 1;
ensure!(count <= 14001, "仓库文件数超过限制");
let entry = std::str::from_utf8(entry)?; let entry = std::str::from_utf8(entry)?;
let (info, path) = entry.split_once('\t').context("Git 树格式错误")?; let (info, path) = entry.split_once('\t').context("Git 树格式错误")?;
let info: Vec<_> = info.split_whitespace().collect(); let info: Vec<_> = info.split_whitespace().collect();
@@ -282,46 +420,201 @@ pub async fn scan_at(repo: &Path, commit: &str) -> Result<Snapshot> {
"禁止符号链接及特殊文件" "禁止符号链接及特殊文件"
); );
let parts: Vec<_> = path.split('/').collect(); let parts: Vec<_> = path.split('/').collect();
let is_index = path == "index.toml";
if !is_index {
ensure!( ensure!(
parts.len() == 3 && parts[0] == "posts", parts.len() == 3 && parts[0] == "posts",
"仓库包含非文章路径" "仅允许 index.toml 和 posts/文件夹/文件"
); );
slug(parts[1])?; slug(parts[1])?;
filename(parts[2])?;
}
let extension = path.rsplit('.').next().unwrap_or("");
let text_file = matches!(extension, "txt" | "md" | "markdown");
ensure!(
is_index || text_file || matches!(extension, "png" | "jpg" | "jpeg" | "webp"),
"仅允许 txt、md、markdown、PNG/JPEG/WebP;禁止证书、配置及隐藏文件"
);
let size = String::from_utf8(git(repo, &["cat-file", "-s", info[2]], None).await?)? let size = String::from_utf8(git(repo, &["cat-file", "-s", info[2]], None).await?)?
.trim() .trim()
.parse::<usize>()?; .parse::<usize>()?;
let limit = if parts[2] == "index.md" { let limit = if is_index {
65536
} else if text_file {
MAX_MARKDOWN MAX_MARKDOWN
} else { } else {
2 * 1024 * 1024 2 * 1024 * 1024
}; };
ensure!(size <= limit, "文件超过大小限制"); ensure!(size <= limit, "文件超过大小限制");
total += size;
ensure!(total <= MAX_SNAPSHOT, "作者仓库超过 64 MiB 配额");
let bytes = git(repo, &["cat-file", "blob", info[2]], None).await?; let bytes = git(repo, &["cat-file", "blob", info[2]], None).await?;
total += bytes.len(); if is_index {
ensure!(total <= MAX_SNAPSHOT, "内容仓库超过 64 MiB 配额"); index = Some(String::from_utf8(bytes).context("index.toml 必须为 UTF-8"));
let group = grouped.entry(parts[1].into()).or_default(); continue;
if parts[2] == "index.md" { }
group.0 = Some(String::from_utf8(bytes)?); let folder = grouped.entry(format!("posts/{}", parts[1])).or_default();
folder.bytes += bytes.len();
ensure!(folder.bytes <= 6 * 1024 * 1024, "文章目录不得超过 6 MiB");
if text_file {
let text = String::from_utf8(bytes).context("文本必须为 UTF-8")?;
validate_text(&text)?;
folder
.texts
.insert(parts[2].into(), text.replace("\r\n", "\n"));
} else { } else {
group.1.push(Asset { let asset = Asset {
name: parts[2].into(), name: parts[2].into(),
data: STANDARD.encode(bytes), data: STANDARD.encode(bytes),
}); };
decode_asset(&asset)?;
folder.assets.push(asset);
ensure!(folder.assets.len() <= 12, "每个目录最多 12 张图片");
} }
ensure!(grouped.len() <= 1000, "最多 1000 篇文章"); ensure!(grouped.len() <= 1000, "最多 1000 个文章目录");
} }
for folder in grouped.values() {
for text in folder.texts.values() {
validate_markdown(text, &folder.assets)?;
}
}
// Index/schema failures stage the commit, while security failures above reject the push.
let index = match index {
Some(Ok(text)) => match manifest(&text) {
Ok(index) => index,
Err(error) => return Ok(Scan::Pending(error.to_string())),
},
Some(Err(error)) => return Ok(Scan::Pending(error.to_string())),
None => return Ok(Scan::Pending("缺少 index.toml".into())),
};
let result = build_posts(index, &mut grouped, commit);
match result {
Ok(posts) => Ok(Scan::Ready(Snapshot {
commit: commit.into(),
posts,
revisions: BTreeMap::new(),
pending: BTreeMap::new(),
})),
Err(error) => Ok(Scan::Pending(error.to_string())),
}
}
fn local_target(url: &str) -> &str {
url.split('#')
.next()
.unwrap_or("")
.strip_prefix("./")
.unwrap_or_else(|| url.split('#').next().unwrap_or(""))
}
fn is_external(url: &str) -> bool {
url.starts_with("https://")
|| url.starts_with("http://")
|| url.starts_with("mailto:")
|| url.starts_with('/')
|| url.starts_with('#')
}
fn build_posts(
index: Vec<ManifestEntry>,
grouped: &mut BTreeMap<String, Folder>,
commit: &str,
) -> Result<BTreeMap<String, Post>> {
use pulldown_cmark::{Event, Parser, Tag};
let mut posts = BTreeMap::new(); let mut posts = BTreeMap::new();
for (key, (markdown, assets)) in grouped { for article in index {
let post = parse(markdown.context("文章缺少 index.md")?, assets)?; let Some(folder) = grouped.remove(&article.folder) else {
ensure!(post.meta.slug == key, "slug 与 Git 路径不一致"); continue;
posts.insert(key, post); };
// A valid index with no entry document denotes an absent article.
let Some(body) = folder.texts.get(&article.entry).cloned() else {
continue;
};
let mut pages = BTreeMap::new();
let mut queue = vec![article.entry.clone()];
let mut images = std::collections::BTreeSet::new();
if let Some(cover) = &article.cover {
images.insert(cover.clone());
} }
Ok(Snapshot { commit, posts }) while let Some(name) = queue.pop() {
if pages.contains_key(&name) {
continue;
}
let text = folder
.texts
.get(&name)
.context("文章内链接指向不存在的页面")?;
for event in Parser::new_ext(text, pulldown_cmark::Options::all()) {
match event {
Event::Start(Tag::Link { dest_url, .. }) if !is_external(&dest_url) => {
let target = local_target(&dest_url);
ensure!(
folder.texts.contains_key(target),
"文章内链接指向不存在的页面"
);
queue.push(target.into());
}
Event::Start(Tag::Image { dest_url, .. }) => {
images.insert(local_target(&dest_url).into());
}
_ => {}
}
}
pages.insert(name, text.clone());
}
ensure!(
images
.iter()
.all(|name| folder.assets.iter().any(|a| &a.name == name)),
"封面或图片不存在"
);
let assets = folder
.assets
.into_iter()
.filter(|a| images.contains(&a.name))
.collect();
let id = article.id;
posts.insert(
id.clone(),
Post {
meta: Metadata {
title: article.title,
slug: id.clone(),
description: article.description,
tags: article.tags,
cover: article.cover,
status: if article.is_public {
Status::Published
} else {
Status::Draft
},
date: String::new(),
updated: String::new(),
created_at: String::new(),
},
markdown: body,
entry: article.entry.clone(),
pages,
assets,
author: String::new(),
article_id: id,
path: format!("{}/{}", article.folder, article.entry),
git_commit: commit.into(),
format: if article.entry.ends_with(".txt") {
"text"
} else {
"markdown"
}
.into(),
},
);
}
Ok(posts)
} }
pub async fn persist(data: &Path, snapshot: &Snapshot) -> Result<()> { pub async fn persist(data: &Path, snapshot: &Snapshot) -> Result<()> {
let public = Snapshot { let public = Snapshot {
commit: snapshot.commit.clone(), commit: snapshot.commit.clone(),
revisions: BTreeMap::new(),
pending: BTreeMap::new(),
posts: snapshot posts: snapshot
.posts .posts
.iter() .iter()
@@ -340,12 +633,7 @@ pub async fn persist(data: &Path, snapshot: &Snapshot) -> Result<()> {
pub fn validate_markdown(body: &str, assets: &[Asset]) -> Result<()> { pub fn validate_markdown(body: &str, assets: &[Asset]) -> Result<()> {
use pulldown_cmark::{Event, Parser, Tag}; use pulldown_cmark::{Event, Parser, Tag};
ensure!( validate_text(body)?;
!body.chars().any(|c| c == '\0'
|| (c.is_control() && !"\n\r\t".contains(c))
|| matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')),
"禁止控制符及双向隐藏字符"
);
for event in Parser::new_ext(body, pulldown_cmark::Options::all()) { for event in Parser::new_ext(body, pulldown_cmark::Options::all()) {
match event { match event {
Event::Html(_) | Event::InlineHtml(_) => anyhow::bail!("Markdown 不允许原始 HTML"), Event::Html(_) | Event::InlineHtml(_) => anyhow::bail!("Markdown 不允许原始 HTML"),
@@ -355,7 +643,15 @@ pub fn validate_markdown(body: &str, assets: &[Asset]) -> Result<()> {
|| dest_url.starts_with("http://") || dest_url.starts_with("http://")
|| dest_url.starts_with("mailto:") || dest_url.starts_with("mailto:")
|| dest_url.starts_with('#') || dest_url.starts_with('#')
|| (dest_url.starts_with('/') && !dest_url.starts_with("//")), || (dest_url.starts_with('/') && !dest_url.starts_with("//"))
|| (!is_external(&dest_url) && {
let target = local_target(&dest_url);
filename(target).is_ok()
&& matches!(
target.rsplit('.').next(),
Some("md" | "markdown" | "txt")
)
}),
"链接协议不允许" "链接协议不允许"
); );
ensure!( ensure!(
@@ -364,7 +660,7 @@ pub fn validate_markdown(body: &str, assets: &[Asset]) -> Result<()> {
); );
} }
Event::Start(Tag::Image { dest_url, .. }) => ensure!( Event::Start(Tag::Image { dest_url, .. }) => ensure!(
assets.iter().any(|a| a.name == dest_url.as_ref()), assets.iter().any(|a| a.name == local_target(&dest_url)),
"图片必须引用同文章已验证的图片文件" "图片必须引用同文章已验证的图片文件"
), ),
_ => {} _ => {}
+43 -2
View File
@@ -45,6 +45,30 @@ pub async fn rebuild(snapshot: &Snapshot) -> Result<()> {
db.query("CREATE TABLE blog_tags (slug TEXT, tag TEXT, git_commit TEXT)") db.query("CREATE TABLE blog_tags (slug TEXT, tag TEXT, git_commit TEXT)")
.await?; .await?;
} }
if db
.query("SELECT id FROM blog_articles LIMIT 1")
.await
.is_err()
{
db.query("CREATE TABLE blog_articles (id TEXT PRIMARY KEY, author TEXT, article_id TEXT, title TEXT, description TEXT, status TEXT, published_at TEXT, updated_at TEXT, path TEXT, format TEXT, git_commit TEXT)").await?;
}
db.query("BEGIN").await?;
db.query("DELETE FROM blog_articles").await?;
for post in snapshot.posts.values() {
let meta = &post.meta;
let status = match meta.status {
Status::Draft => "draft",
Status::Published => "published",
};
let q = Query::new("INSERT INTO blog_articles (id, author, article_id, title, description, status, published_at, updated_at, path, format, git_commit) VALUES (")
.bind(meta.slug.as_str()).push(", ").bind(post.author.as_str()).push(", ")
.bind(post.article_id.as_str()).push(", ").bind(meta.title.as_str()).push(", ")
.bind(meta.description.as_str()).push(", ").bind(status).push(", ")
.bind(meta.date.as_str()).push(", ").bind(meta.updated.as_str()).push(", ")
.bind(post.path.as_str()).push(", ").bind(post.format.as_str()).push(", ")
.bind(post.git_commit.as_str()).push(")");
db.query(q.as_str()).await?;
}
db.query("DELETE FROM blog_tags").await?; db.query("DELETE FROM blog_tags").await?;
db.query("DELETE FROM blog_posts").await?; db.query("DELETE FROM blog_posts").await?;
for post in snapshot for post in snapshot
@@ -56,7 +80,7 @@ pub async fn rebuild(snapshot: &Snapshot) -> Result<()> {
let q = Query::new("INSERT INTO blog_posts (slug, title, description, published_at, updated_at, path, git_commit) VALUES (") let q = Query::new("INSERT INTO blog_posts (slug, title, description, published_at, updated_at, path, git_commit) VALUES (")
.bind(meta.slug.as_str()).push(", ").bind(meta.title.as_str()).push(", ") .bind(meta.slug.as_str()).push(", ").bind(meta.title.as_str()).push(", ")
.bind(meta.description.as_str()).push(", ").bind(meta.date.as_str()).push(", ") .bind(meta.description.as_str()).push(", ").bind(meta.date.as_str()).push(", ")
.bind(meta.updated.as_str()).push(", ").bind(format!("posts/{}/index.md", meta.slug).as_str()) .bind(meta.updated.as_str()).push(", ").bind(post.path.as_str())
.push(", ").bind(snapshot.commit.as_str()).push(")"); .push(", ").bind(snapshot.commit.as_str()).push(")");
db.query(q.as_str()).await?; db.query(q.as_str()).await?;
for tag in &meta.tags { for tag in &meta.tags {
@@ -70,6 +94,7 @@ pub async fn rebuild(snapshot: &Snapshot) -> Result<()> {
db.query(q.as_str()).await?; db.query(q.as_str()).await?;
} }
} }
db.query("COMMIT").await?;
db.close().await; db.close().await;
Ok(()) Ok(())
} }
@@ -131,7 +156,23 @@ pub async fn consistent(snapshot: &Snapshot) -> Result<bool> {
.filter(|p| p.meta.status == Status::Published) .filter(|p| p.meta.status == Status::Published)
.map(|p| p.meta.tags.len()) .map(|p| p.meta.tags.len())
.sum::<usize>() as u64; .sum::<usize>() as u64;
let matches = !result.truncated let articles = db
.query("SELECT id, git_commit FROM blog_articles LIMIT 1000")
.await?;
let expected_articles: std::collections::BTreeSet<_> = snapshot
.posts
.values()
.map(|p| (p.meta.slug.as_str(), p.git_commit.as_str()))
.collect();
let actual_articles: std::collections::BTreeSet<_> = articles
.rows
.iter()
.filter_map(|row| Some((row.first()?.as_str()?, row.get(1)?.as_str()?)))
.collect();
let matches = !articles.truncated
&& articles.rows.len() == expected_articles.len()
&& actual_articles == expected_articles
&& !result.truncated
&& result.rows.len() == expected.len() && result.rows.len() == expected.len()
&& actual == expected && actual == expected
&& count && count
+73 -19
View File
@@ -85,7 +85,27 @@ async fn public_post(
// Relaxed is sufficient: this independent counter does not synchronize other state. // Relaxed is sufficient: this independent counter does not synchronize other state.
app.views.fetch_add(1, Ordering::Relaxed); app.views.fetch_add(1, Ordering::Relaxed);
Ok(Json( Ok(Json(
json!({"meta": post.meta, "markdown": post.markdown, "assets": post.assets.iter().map(|a| json!({"name": a.name, "url": format!("/api/assets/{key}/{}", a.name)})).collect::<Vec<_>>()}), json!({"meta": post.meta, "markdown": post.markdown, "format": post.format, "author": post.author, "article_id": post.article_id, "entry": post.entry, "pages": post.pages.keys().map(|name| json!({"name":name,"url":format!("/api/posts/{key}/pages/{name}")})).collect::<Vec<_>>(), "assets": post.assets.iter().map(|a| json!({"name": a.name, "url": format!("/api/assets/{key}/{}", a.name)})).collect::<Vec<_>>()}),
))
}
async fn page(
State(app): State<Arc<App>>,
Path((key, name)): Path<(String, String)>,
) -> ApiResult<Json<Value>> {
let snapshot = app.snapshot.read().await;
let post = snapshot
.posts
.get(&key)
.filter(|p| p.meta.status == Status::Published)
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "文章不存在".into()))?;
let text = post
.pages
.get(&name)
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "页面不存在".into()))?;
Ok(Json(
json!({"meta":post.meta,"name":name,"markdown":text,"format":if name.ends_with(".txt") { "text" } else { "markdown" },
"pages":post.pages.keys().map(|page| json!({"name":page,"url":format!("/api/posts/{key}/pages/{page}")})).collect::<Vec<_>>(),
"assets":post.assets.iter().map(|a| json!({"name":a.name,"url":format!("/api/assets/{key}/{}",a.name)})).collect::<Vec<_>>() }),
)) ))
} }
async fn asset( async fn asset(
@@ -132,7 +152,7 @@ async fn admin_status(State(app): State<Arc<App>>, headers: HeaderMap) -> ApiRes
accounts::authorize(&app, &headers, false, true).await?; accounts::authorize(&app, &headers, false, true).await?;
let snapshot = app.snapshot.read().await; let snapshot = app.snapshot.read().await;
Ok(Json( Ok(Json(
json!({"commit": snapshot.commit, "total_posts": snapshot.posts.len(), "drafts": snapshot.posts.values().filter(|p| p.meta.status == Status::Draft).count(), "indexed_commit": *app.indexed.read().await}), json!({"commit": snapshot.commit, "total_posts": snapshot.posts.len(), "drafts": snapshot.posts.values().filter(|p| p.meta.status == Status::Draft).count(), "indexed_commit": *app.indexed.read().await, "pending": snapshot.pending}),
)) ))
} }
async fn sync_index(app: &App, snapshot: &Snapshot) { async fn sync_index(app: &App, snapshot: &Snapshot) {
@@ -153,6 +173,7 @@ fn router(app: Arc<App>) -> Router {
) )
.route("/api/posts", get(posts)) .route("/api/posts", get(posts))
.route("/api/posts/{slug}", get(public_post)) .route("/api/posts/{slug}", get(public_post))
.route("/api/posts/{slug}/pages/{name}", get(page))
.route("/api/assets/{slug}/{name}", get(asset)) .route("/api/assets/{slug}/{name}", get(asset))
.route("/api/stats", get(stats)) .route("/api/stats", get(stats))
.route("/api/auth/register", post(accounts::register)) .route("/api/auth/register", post(accounts::register))
@@ -186,19 +207,32 @@ fn router(app: Arc<App>) -> Router {
)) ))
.with_state(app) .with_state(app)
} }
async fn refresh(app: &App, repo: &std::path::Path, source: &std::path::Path) -> Result<()> { async fn refresh(app: &App, source: &std::path::Path) -> Result<()> {
// Recover a previously committed ref even if snapshot publication was interrupted. let current = app.snapshot.read().await.clone();
let mut current = app.snapshot.read().await.clone(); let identities: Vec<_> = app
let head = .accounts
String::from_utf8(content::git(repo, &["rev-parse", "refs/heads/main"], None).await?)?; .lock()
if current.commit != head.trim() { .await
current = content::scan(repo).await?; .grants
} .publishers
let snapshot = ssh::import(repo, source, &current).await?; .keys()
if snapshot.commit != app.snapshot.read().await.commit { .cloned()
.collect();
let snapshot = ssh::import_authors(&app.data, source, &current, &identities).await?;
if snapshot.commit != current.commit || snapshot.pending != current.pending {
accounts::atomic(
&app.data.join("collection.json"),
&serde_json::to_vec(&snapshot)?,
0o600,
)
.await?;
content::persist(&app.data, &snapshot).await?; content::persist(&app.data, &snapshot).await?;
info!(action = "published", commit = %snapshot.commit); info!(action = "published", revision = %snapshot.commit);
let changed = snapshot.commit != current.commit;
*app.snapshot.write().await = snapshot; *app.snapshot.write().await = snapshot;
if changed {
*app.indexed.write().await = None;
}
} }
Ok(()) Ok(())
} }
@@ -238,7 +272,21 @@ async fn main() -> Result<()> {
.open(data.join("writer.lock"))?; .open(data.join("writer.lock"))?;
fs2::FileExt::try_lock_exclusive(&lock_file).context("内容目录已被另一个 API 进程使用")?; fs2::FileExt::try_lock_exclusive(&lock_file).context("内容目录已被另一个 API 进程使用")?;
let repo = content::init(&data).await?; let repo = content::init(&data).await?;
let snapshot = content::scan(&repo).await?; let snapshot = match tokio::fs::read(data.join("collection.json")).await {
Ok(bytes) => {
serde_json::from_slice(&bytes).context("文章集合快照损坏,请从私有作者仓库恢复")?
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
ensure!(
content::git(&repo, &["ls-tree", "-r", "--name-only", "HEAD"], None)
.await?
.is_empty(),
"检测到旧共享文章仓库,请先按 README 迁移到作者独立仓库"
);
Snapshot::default()
}
Err(error) => return Err(error.into()),
};
content::persist(&data, &snapshot).await?; content::persist(&data, &snapshot).await?;
if command.as_deref() == Some("rebuild-index") { if command.as_deref() == Some("rebuild-index") {
index::rebuild(&snapshot).await?; index::rebuild(&snapshot).await?;
@@ -263,9 +311,9 @@ async fn main() -> Result<()> {
"PUBLIC_ORIGIN 必须为不带末尾斜杠的站点 origin" "PUBLIC_ORIGIN 必须为不带末尾斜杠的站点 origin"
); );
let source = PathBuf::from( let source = PathBuf::from(
std::env::var("INGRESS_REPO").unwrap_or_else(|_| "/srv/ingress/content.git".into()), std::env::var("INGRESS_DIR").unwrap_or_else(|_| "/srv/ingress/authors".into()),
); );
ensure!(source.is_absolute(), "INGRESS_REPO 必须是绝对路径"); ensure!(source.is_absolute(), "INGRESS_DIR 必须是绝对路径");
let ssh_config = PathBuf::from( let ssh_config = PathBuf::from(
std::env::var("CHUBLOG_SSH_CONFIG") std::env::var("CHUBLOG_SSH_CONFIG")
.unwrap_or_else(|_| data.join("publishers.json").to_string_lossy().into_owned()), .unwrap_or_else(|_| data.join("publishers.json").to_string_lossy().into_owned()),
@@ -273,7 +321,7 @@ async fn main() -> Result<()> {
let accounts = accounts::load(&data, &ssh_config, source.clone()).await?; let accounts = accounts::load(&data, &ssh_config, source.clone()).await?;
ensure!( ensure!(
accounts.grants.repository == source, accounts.grants.repository == source,
"授权配置仓库与 INGRESS_REPO 不一致" "授权配置仍指向旧共享仓库或与 INGRESS_DIR 不一致,请先按 README 迁移授权配置"
); );
let app = Arc::new(App { let app = Arc::new(App {
data, data,
@@ -290,11 +338,14 @@ async fn main() -> Result<()> {
let worker_app = app.clone(); let worker_app = app.clone();
let worker = tokio::spawn(async move { let worker = tokio::spawn(async move {
let mut ticks = 0; let mut ticks = 0;
let mut retry_at = tokio::time::Instant::now();
loop { loop {
if let Err(error) = refresh(&worker_app, &repo, &source).await { if let Err(error) = refresh(&worker_app, &source).await {
warn!(action = "import_pending", error = %error); warn!(action = "import_pending", error = %error);
} }
if ticks % 30 == 0 { if (ticks % 30 == 0 || worker_app.indexed.read().await.is_none())
&& tokio::time::Instant::now() >= retry_at
{
let snapshot = worker_app.snapshot.read().await.clone(); let snapshot = worker_app.snapshot.read().await.clone();
if worker_app.indexed.read().await.as_deref() != Some(snapshot.commit.as_str()) { if worker_app.indexed.read().await.as_deref() != Some(snapshot.commit.as_str()) {
sync_index(&worker_app, &snapshot).await; sync_index(&worker_app, &snapshot).await;
@@ -306,6 +357,9 @@ async fn main() -> Result<()> {
*worker_app.indexed.write().await = None; *worker_app.indexed.write().await = None;
sync_index(&worker_app, &snapshot).await; sync_index(&worker_app, &snapshot).await;
} }
if worker_app.indexed.read().await.is_none() {
retry_at = tokio::time::Instant::now() + Duration::from_secs(30);
}
} }
ticks += 1; ticks += 1;
tokio::time::sleep(Duration::from_secs(2)).await; tokio::time::sleep(Duration::from_secs(2)).await;
+166 -66
View File
@@ -1,5 +1,5 @@
use crate::accounts::{Grant, Role}; use crate::accounts::{Grant, Role};
use crate::content::{self, Snapshot}; use crate::content::{self, Scan, Snapshot};
use anyhow::{Context, Result, bail, ensure}; use anyhow::{Context, Result, bail, ensure};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
@@ -44,16 +44,44 @@ fn authorized<'a>(cfg: &'a Config, identity: &str) -> Result<&'a Grant> {
.get(identity) .get(identity)
.filter(|p| p.enabled && p.role != Role::Reader && p.ssh_key.is_some()) .filter(|p| p.enabled && p.role != Role::Reader && p.ssh_key.is_some())
.context("服务器未授权此身份发布文章")?; .context("服务器未授权此身份发布文章")?;
ensure!(
!publisher.prefixes.is_empty()
&& publisher.prefixes.iter().all(|p| !p.is_empty()
&& p.len() <= 64
&& p.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')),
"发布者必须拥有明确 slug 前缀"
);
Ok(publisher) Ok(publisher)
} }
pub fn author_repository(root: &Path, identity: &str) -> Result<PathBuf> {
ensure!(
(3..=32).contains(&identity.len())
&& identity.starts_with(|c: char| c.is_ascii_lowercase())
&& identity
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'),
"作者仓库身份无效"
);
Ok(root.join(format!("{identity}.git")))
}
pub async fn init_author(root: &Path, identity: &str) -> Result<PathBuf> {
let repo = author_repository(root, identity)?;
if !repo.exists() {
tokio::fs::create_dir(&repo).await?;
content::git(
&repo,
&[
"init",
"--bare",
"--initial-branch=main",
"--shared=group",
".",
],
None,
)
.await?;
}
ensure!(
std::fs::symlink_metadata(&repo)?.is_dir(),
"作者仓库必须是普通目录"
);
Ok(repo)
}
fn directory_size(path: &Path) -> Result<u64> { fn directory_size(path: &Path) -> Result<u64> {
let mut total = 0u64; let mut total = 0u64;
for entry in std::fs::read_dir(path)? { for entry in std::fs::read_dir(path)? {
@@ -88,20 +116,22 @@ pub async fn gate(identity: &str) -> Result<()> {
"仅允许对 content.git 执行 Git 推送或读取" "仅允许对 content.git 执行 Git 推送或读取"
); );
let receiving = original.starts_with("git-receive-pack"); let receiving = original.starts_with("git-receive-pack");
directory_size(&cfg.repository)?; let repo = author_repository(&cfg.repository, identity)?;
let lock = std::fs::OpenOptions::new() let lock = std::fs::OpenOptions::new()
.create(true) .create(true)
.truncate(false) .truncate(false)
.write(true) .write(true)
.open(cfg.repository.join("ssh.lock"))?; .open(cfg.repository.join(format!(".{identity}.lock")))?;
fs2::FileExt::try_lock_exclusive(&lock).context("另一位发布者正在访问仓库,请稍后重试")?; fs2::FileExt::try_lock_exclusive(&lock).context("另一位发布者正在访问仓库,请稍后重试")?;
init_author(&cfg.repository, identity).await?;
directory_size(&cfg.repository)?;
let executable = if receiving { let executable = if receiving {
"git-receive-pack" "git-receive-pack"
} else { } else {
"git-upload-pack" "git-upload-pack"
}; };
let mut child = Command::new(executable) let mut child = Command::new(executable)
.arg(&cfg.repository) .arg(&repo)
.env_clear() .env_clear()
.env("PATH", "/usr/bin:/bin") .env("PATH", "/usr/bin:/bin")
.env("HOME", "/nonexistent") .env("HOME", "/nonexistent")
@@ -138,12 +168,7 @@ pub async fn gate(identity: &str) -> Result<()> {
Ok(()) Ok(())
} }
pub async fn validate_change( pub async fn validate_change(repo: &Path, previous: &str, next: &str) -> Result<Scan> {
repo: &Path,
previous: &str,
next: &str,
publisher: &Grant,
) -> Result<Snapshot> {
ensure!( ensure!(
next.len() == 40 next.len() == 40
&& next.bytes().all(|b| b.is_ascii_hexdigit()) && next.bytes().all(|b| b.is_ascii_hexdigit())
@@ -158,36 +183,6 @@ pub async fn validate_change(
"每次只允许推送一个直接继承主分支的提交,首次必须为根提交;不允许合并或强推" "每次只允许推送一个直接继承主分支的提交,首次必须为根提交;不允许合并或强推"
); );
let snapshot = content::scan_at(repo, next).await?; let snapshot = content::scan_at(repo, next).await?;
// Authorize raw Git paths: normalization must not hide edits to another author's bytes.
let changes = if initial {
content::git(repo, &["ls-tree", "--name-only", "-rz", next], None).await?
} else {
content::git(
repo,
&[
"diff-tree",
"--no-commit-id",
"--name-only",
"--no-renames",
"-r",
"-z",
previous,
next,
],
None,
)
.await?
};
for path in changes.split(|b| *b == 0).filter(|p| !p.is_empty()) {
let path = std::str::from_utf8(path)?;
let parts: Vec<_> = path.split('/').collect();
ensure!(parts.len() == 3 && parts[0] == "posts", "修改路径无效");
let key = content::slug(parts[1])?;
ensure!(
publisher.prefixes.iter().any(|p| key.starts_with(p)),
"无权修改文章 {key}"
);
}
Ok(snapshot) Ok(snapshot)
} }
@@ -214,7 +209,15 @@ pub async fn pre_receive() -> Result<()> {
fields[0].len() == 40 && fields[0].bytes().all(|b| b.is_ascii_hexdigit()), fields[0].len() == 40 && fields[0].bytes().all(|b| b.is_ascii_hexdigit()),
"旧提交无效" "旧提交无效"
); );
validate_change(&cfg.repository, fields[0], fields[1], publisher).await?; let repo = author_repository(&cfg.repository, &identity)?;
let actual = std::env::current_dir()?;
ensure!(
std::fs::canonicalize(&repo)? == std::fs::canonicalize(actual)?,
"推送仓库与作者身份不符"
);
if let Scan::Pending(reason) = validate_change(&repo, fields[0], fields[1]).await? {
eprintln!("ChuBlog: index.toml 待修复,提交已暂存,现有文章不变:{reason}");
}
let latest = config(&config_path)?; let latest = config(&config_path)?;
let current = authorized(&latest, &identity)?; let current = authorized(&latest, &identity)?;
ensure!( ensure!(
@@ -228,34 +231,131 @@ pub async fn pre_receive() -> Result<()> {
Ok(()) Ok(())
} }
// Fetch into API-owned storage: the SSH account cannot alter published refs or snapshots. // Each fetch receives only one author's objects into an API-owned private cache.
pub async fn import(repo: &Path, source: &Path, current: &Snapshot) -> Result<Snapshot> { pub async fn import_authors(
content::git( data: &Path,
repo, source: &Path,
current: &Snapshot,
identities: &[String],
) -> Result<Snapshot> {
let cache = data.join("authors");
tokio::fs::create_dir_all(&cache).await?;
let mut combined = current.clone();
for identity in identities {
let incoming = author_repository(source, identity)?;
if !incoming.exists() {
continue;
}
ensure!(
std::fs::symlink_metadata(&incoming)?.is_dir(),
"接收仓库不是普通目录"
);
let result = async {
let author_data = cache.join(identity);
let repo = content::init(&author_data).await?;
let safe = format!("safe.directory={}", incoming.display());
// The initial bare repository may have no main branch until its first push.
let refs = content::git(
&repo,
&[ &[
"fetch", "-c",
"--no-tags", &safe,
"--no-recurse-submodules", "ls-remote",
"--", incoming.to_str().context("接收路径无效")?,
source.to_str().context("接收路径无效")?,
"refs/heads/main", "refs/heads/main",
], ],
None, None,
) )
.await?; .await?;
let commit = String::from_utf8(content::git(repo, &["rev-parse", "FETCH_HEAD"], None).await?)?; if refs.is_empty() {
let commit = commit.trim(); return Ok::<_, anyhow::Error>(None);
if commit == current.commit {
return Ok(current.clone());
} }
let snapshot = content::scan_at(repo, commit).await?;
content::git( content::git(
repo, &repo,
&["update-ref", "refs/heads/main", commit, &current.commit], &[
"-c",
&safe,
"fetch",
"--no-tags",
"--no-recurse-submodules",
"--",
incoming.to_str().context("接收路径无效")?,
"refs/heads/main",
],
None, None,
) )
.await?; .await?;
Ok(snapshot) let commit =
String::from_utf8(content::git(&repo, &["rev-parse", "FETCH_HEAD"], None).await?)?;
let commit = commit.trim();
if current
.revisions
.get(identity)
.is_some_and(|previous| previous == commit)
|| current
.pending
.get(identity)
.is_some_and(|pending| pending.commit == commit)
{
return Ok(None);
}
let snapshot = content::scan_at(&repo, commit).await?;
content::git(&repo, &["update-ref", "refs/heads/main", commit], None).await?;
Ok(Some((commit.to_owned(), snapshot)))
}
.await;
match result {
Ok(Some((commit, Scan::Pending(reason)))) => {
combined
.pending
.insert(identity.clone(), content::PendingIndex { commit, reason });
}
Ok(Some((_, Scan::Ready(snapshot)))) => {
let mut candidate = combined.clone();
candidate.posts.retain(|_, post| post.author != *identity);
for (id, mut post) in snapshot.posts {
post.author = identity.clone();
// Neither username nor local ID allows '~', so this is collision-free.
let key = format!("{identity}~{id}");
post.meta.slug = key.clone();
let now =
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
content::stamp(&mut post, combined.posts.get(&key), &now);
candidate.posts.insert(key, post);
}
let bytes: usize = candidate
.posts
.values()
.map(|p| {
p.markdown.len()
+ p.assets.iter().map(|a| a.data.len() * 3 / 4).sum::<usize>()
})
.sum();
if candidate.posts.len() > 1000 || bytes > content::MAX_SNAPSHOT {
tracing::warn!(action = "author_import_pending", author = %identity, "站点文章数量或容量超过上限");
continue;
}
candidate
.revisions
.insert(identity.clone(), snapshot.commit);
candidate.pending.remove(identity);
combined = candidate;
}
Ok(None) => {}
Err(error) => {
tracing::warn!(action = "author_import_pending", author = %identity, error = %error)
}
}
}
let repo = content::init(data).await?;
let digest = content::git(
&repo,
&["hash-object", "--stdin"],
Some(&serde_json::to_vec(&combined.revisions)?),
)
.await?;
combined.commit = String::from_utf8(digest)?.trim().into();
Ok(combined)
} }
pub fn authorized_keys() -> Result<()> { pub fn authorized_keys() -> Result<()> {
+237 -95
View File
@@ -9,29 +9,28 @@ fn markdown(key: &str, status: &str, body: &str) -> String {
"---\ntitle: Test\nslug: {key}\ndate: '2026-10-15'\nupdated: '2026-10-15'\nstatus: {status}\ntags: [Rust]\ndescription: Test article\n---\n\n{body}\n" "---\ntitle: Test\nslug: {key}\ndate: '2026-10-15'\nupdated: '2026-10-15'\nstatus: {status}\ntags: [Rust]\ndescription: Test article\n---\n\n{body}\n"
) )
} }
fn grant(prefix: &str) -> Grant {
Grant {
enabled: true,
role: Role::Publisher,
prefixes: vec![prefix.into()],
ssh_key: None,
}
}
async fn commit( async fn commit(
repo: &std::path::Path, repo: &std::path::Path,
parent: Option<&str>, parent: Option<&str>,
files: &[(&str, &str, &str)], files: &[(&str, &str, &str)],
) -> String {
let files: Vec<_> = files
.iter()
.map(|(path, mode, body)| (*path, *mode, body.as_bytes()))
.collect();
commit_bytes(repo, parent, &files).await
}
async fn commit_bytes(
repo: &std::path::Path,
parent: Option<&str>,
files: &[(&str, &str, &[u8])],
) -> String { ) -> String {
content::git(repo, &["read-tree", "--empty"], None) content::git(repo, &["read-tree", "--empty"], None)
.await .await
.unwrap(); .unwrap();
for (path, mode, body) in files { for (path, mode, body) in files {
let object = String::from_utf8( let object = String::from_utf8(
content::git( content::git(repo, &["hash-object", "-w", "--stdin"], Some(body))
repo,
&["hash-object", "-w", "--stdin"],
Some(body.as_bytes()),
)
.await .await
.unwrap(), .unwrap(),
) )
@@ -117,117 +116,143 @@ fn rejects_active_content_and_unsafe_metadata_but_keeps_inert_code() {
.is_err() .is_err()
); );
} }
#[test]
fn manifest_requires_unique_ids_safe_folders_and_bounded_text() {
let index =
content::manifest("first posts/writing article.md\nsecond posts/notes body.markdown\n")
.unwrap();
assert_eq!(index["first"].folder, "posts/writing");
assert_eq!(index["first"].file, "article.md");
for text in [
"first posts/a a.md\nfirst posts/b b.md",
"first posts/a a.md\nsecond posts/a b.md",
"first ../escape",
"first posts/../escape",
"first posts/.git",
"first posts/a ../a.md",
"first posts/a .gitignore",
"first posts/a a.pem",
"../escape posts/a",
"first posts/a\0",
"-----BEGIN CERTIFICATE-----",
"first posts/a\u{202e}",
] {
assert!(content::manifest(text).is_err(), "accepted {text}");
}
assert!(content::manifest(&" ".repeat(65537)).is_err());
assert!(ssh::author_repository(std::path::Path::new("/private"), "../bob").is_err());
}
#[tokio::test] #[tokio::test]
async fn git_checks_namespace_history_and_file_modes_before_publication() { async fn git_manifest_controls_publication_and_validates_unlisted_files_and_history() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let repo = content::init(dir.path()).await.unwrap(); let repo = content::init(dir.path()).await.unwrap();
let zero = "0000000000000000000000000000000000000000"; let zero = "0000000000000000000000000000000000000000";
let first = commit( let body = markdown("first", "published", "Hello");
&repo, let files = [
None, ("index.txt", "100644", "first posts/writing index.md\n"),
&[( ("posts/writing/index.md", "100644", body.as_str()),
"posts/alice-first/index.md", ];
"100644", let first = commit(&repo, None, &files).await;
&markdown("alice-first", "published", "Hello"), let snapshot = ssh::validate_change(&repo, zero, &first).await.unwrap();
)], assert_eq!(snapshot.posts["first"].path, "posts/writing/index.md");
) let removed = commit(
.await;
let snapshot = ssh::validate_change(&repo, zero, &first, &grant("alice-"))
.await
.unwrap();
assert_eq!(snapshot.posts.len(), 1);
assert!(
ssh::validate_change(&repo, zero, &first, &grant("bob-"))
.await
.is_err()
);
let crlf = commit(
&repo, &repo,
Some(&first), Some(&first),
&[( &[("index.txt", "100644", ""), files[1]],
"posts/alice-first/index.md",
"100644",
&markdown("alice-first", "published", "Hello").replace('\n', "\r\n"),
)],
) )
.await; .await;
assert!( assert!(
ssh::validate_change(&repo, &first, &crlf, &grant("bob-")) ssh::validate_change(&repo, &first, &removed)
.await
.unwrap()
.posts
.is_empty()
);
// Unlisted content is inert but must never bypass the allowlist or validation.
for (path, mode, value) in [
(".gitignore", "100644", "secret"),
("posts/writing/cert.pem", "100644", "cert"),
(
"posts/writing/secret.txt",
"100644",
"-----BEGIN PRIVATE KEY-----",
),
(
"posts/writing/hidden.md",
"100644",
"<script>alert(1)</script>",
),
("posts/writing/fake.png", "100644", "not an image"),
("posts/other/index.md", "120000", "/etc/passwd"),
("posts/other/index.md", "100755", "executable"),
("posts/writing/payload.bin", "100644", "binary"),
] {
let candidate = commit(
&repo,
Some(&first),
&[files[0], files[1], (path, mode, value)],
)
.await;
assert!(
ssh::validate_change(&repo, &first, &candidate)
.await .await
.is_err(), .is_err(),
"normalization must not bypass path authorization" "accepted {path}"
); );
let second = commit( }
let missing = commit(
&repo, &repo,
Some(&first), Some(&first),
&[( &[("index.txt", "100644", "first posts/missing index.md")],
"posts/alice-first/index.md",
"100644",
&markdown("alice-first", "draft", "Private"),
)],
) )
.await; .await;
ssh::validate_change(&repo, &first, &second, &grant("alice-")) assert!(content::scan_at(&repo, &missing).await.is_err());
.await let duplicate_body = commit(
.unwrap();
let third = commit(
&repo,
Some(&second),
&[(
"posts/alice-first/index.md",
"100644",
&markdown("alice-first", "published", "Skipped commit"),
)],
)
.await;
assert!(
ssh::validate_change(&repo, &first, &third, &grant("alice-"))
.await
.is_err()
);
let link = commit(
&repo, &repo,
Some(&first), Some(&first),
&[("posts/alice-first/index.md", "120000", "/etc/passwd")], &[
files[0],
files[1],
("posts/writing/index.txt", "100644", body.as_str()),
],
) )
.await; .await;
assert!( assert_eq!(
ssh::validate_change(&repo, &first, &link, &grant("alice-")) content::scan_at(&repo, &duplicate_body)
.await .await
.is_err() .unwrap()
.posts["first"]
.path,
"posts/writing/index.md"
); );
let executable = commit( let skipped = commit(&repo, Some(&removed), &files).await;
assert!(ssh::validate_change(&repo, &first, &skipped).await.is_err());
assert!(ssh::validate_change(&repo, &first, zero).await.is_err());
let plain = commit(
&repo, &repo,
Some(&first), Some(&first),
&[( &[
"posts/alice-first/index.md", ("index.txt", "100644", "first posts/writing index.txt"),
"100755", ("posts/writing/index.txt", "100644", body.as_str()),
&markdown("alice-first", "published", "Hello"), ],
)],
) )
.await; .await;
assert!( assert_eq!(
ssh::validate_change(&repo, &first, &executable, &grant("alice-")) content::scan_at(&repo, &plain).await.unwrap().posts["first"].format,
.await "text"
.is_err()
); );
let hidden = commit( let draft_body = markdown("first", "draft", "Secret");
let draft = commit(
&repo, &repo,
Some(&first), Some(&first),
&[(".github/workflows/run.yml", "100644", "bad")], &[
("index.txt", "100644", "first posts/writing index.markdown"),
("posts/writing/index.markdown", "100644", &draft_body),
],
) )
.await; .await;
assert!( content::persist(dir.path(), &content::scan_at(&repo, &draft).await.unwrap())
ssh::validate_change(&repo, &first, &hidden, &grant("alice-"))
.await
.is_err()
);
assert!(
ssh::validate_change(&repo, &first, zero, &grant("alice-"))
.await
.is_err()
);
content::persist(dir.path(), &content::scan_at(&repo, &second).await.unwrap())
.await .await
.unwrap(); .unwrap();
let public: Snapshot = serde_json::from_slice( let public: Snapshot = serde_json::from_slice(
@@ -236,7 +261,123 @@ async fn git_checks_namespace_history_and_file_modes_before_publication() {
.unwrap(), .unwrap(),
) )
.unwrap(); .unwrap();
assert!(public.posts.is_empty(), "draft leaked into public snapshot"); assert!(public.posts.is_empty());
}
#[tokio::test]
async fn custom_body_selection_ignores_unindexed_text_and_unreferenced_images() {
let dir = tempfile::tempdir().unwrap();
let repo = content::init(dir.path()).await.unwrap();
let body = markdown("first", "published", "![cover](cover.png)").replace("slug: first\n", "");
let mut image = std::io::Cursor::new(Vec::new());
image::DynamicImage::new_rgb8(1, 1)
.write_to(&mut image, image::ImageFormat::Png)
.unwrap();
let commit = commit_bytes(
&repo,
None,
&[
(
"index.txt",
"100644",
b"first posts/writing article.markdown\n",
),
("posts/writing/article.markdown", "100644", body.as_bytes()),
("posts/writing/notes.txt", "100644", b"Unselected notes"),
("posts/unlisted/draft.txt", "100644", b"Not registered"),
("posts/writing/cover.png", "100644", image.get_ref()),
("posts/writing/private.png", "100644", image.get_ref()),
],
)
.await;
let snapshot = content::scan_at(&repo, &commit).await.unwrap();
assert_eq!(snapshot.posts.len(), 1);
let post = &snapshot.posts["first"];
assert_eq!(post.path, "posts/writing/article.markdown");
assert_eq!(post.meta.slug, "first");
assert_eq!(post.assets.len(), 1);
assert_eq!(post.assets[0].name, "cover.png");
assert!(
!serde_json::to_string(&snapshot)
.unwrap()
.contains("Unselected notes")
);
}
#[tokio::test]
async fn author_import_isolates_objects_and_handles_same_ids_index_deletion_and_recovery() {
let dir = tempfile::tempdir().unwrap();
let ingress = dir.path().join("ingress");
std::fs::create_dir(&ingress).unwrap();
let alice = ssh::init_author(&ingress, "alice").await.unwrap();
let bob = ssh::init_author(&ingress, "bob").await.unwrap();
let a_body = markdown("first", "draft", "Alice private body");
let b_body = markdown("first", "published", "Bob public body");
let a = commit(
&alice,
None,
&[
("index.txt", "100644", "first posts/a index.md"),
("posts/a/index.md", "100644", &a_body),
],
)
.await;
let b = commit(
&bob,
None,
&[
("index.txt", "100644", "first posts/b index.md"),
("posts/b/index.md", "100644", &b_body),
],
)
.await;
content::git(&alice, &["update-ref", "refs/heads/main", &a], None)
.await
.unwrap();
content::git(&bob, &["update-ref", "refs/heads/main", &b], None)
.await
.unwrap();
assert!(
content::git(&alice, &["cat-file", "-t", &b], None)
.await
.is_err(),
"Bob objects leaked into Alice repository"
);
let data = dir.path().join("api");
let names = vec!["alice".into(), "bob".into()];
let first = ssh::import_authors(&data, &ingress, &Snapshot::default(), &names)
.await
.unwrap();
assert_eq!(first.posts.len(), 2);
assert_eq!(first.posts["alice~first"].author, "alice");
assert_eq!(first.posts["bob~first"].article_id, "first");
let same = ssh::import_authors(&data, &ingress, &first, &names)
.await
.unwrap();
assert_eq!(same.commit, first.commit);
let removed = commit(
&bob,
Some(&b),
&[
("index.txt", "100644", ""),
("posts/b/index.md", "100644", &b_body),
],
)
.await;
content::git(&bob, &["update-ref", "refs/heads/main", &removed], None)
.await
.unwrap();
let next = ssh::import_authors(&data, &ingress, &first, &names)
.await
.unwrap();
assert!(!next.posts.contains_key("bob~first"));
assert!(next.posts.contains_key("alice~first"));
// Recover all authors from private Git caches after losing the derived collection.
let rebuilt = ssh::import_authors(&data, &ingress, &Snapshot::default(), &names)
.await
.unwrap();
assert_eq!(rebuilt.commit, next.commit);
assert_eq!(rebuilt.posts.len(), 1);
} }
#[test] #[test]
fn ssh_keys_cannot_inject_options_or_commands() { fn ssh_keys_cannot_inject_options_or_commands() {
@@ -347,6 +488,7 @@ async fn app(dir: &std::path::Path) -> Arc<App> {
hash_slots: Arc::new(Semaphore::new(2)), hash_slots: Arc::new(Semaphore::new(2)),
snapshot: RwLock::new(Snapshot { snapshot: RwLock::new(Snapshot {
commit: "test".into(), commit: "test".into(),
revisions: BTreeMap::new(),
posts: BTreeMap::from([ posts: BTreeMap::from([
("alice-public".into(), published), ("alice-public".into(), published),
("alice-secret".into(), draft), ("alice-secret".into(), draft),
@@ -459,7 +601,7 @@ async fn registration_login_grants_revocation_and_private_content_boundaries() {
.unwrap() .unwrap()
.to_owned(); .to_owned();
let admin_csrf = value["csrf"].as_str().unwrap().to_owned(); let admin_csrf = value["csrf"].as_str().unwrap().to_owned();
let input = json!({"enabled":true,"role":"publisher","prefixes":["alice-"]}); let input = json!({"enabled":true,"role":"publisher"});
let (status, _, _) = request( let (status, _, _) = request(
app.clone(), app.clone(),
"PATCH", "PATCH",
+1
View File
@@ -0,0 +1 @@
welcome posts/welcome index.md
+1 -1
View File
@@ -8,7 +8,7 @@ RUN groupadd -g 10002 ingress && useradd -u 10001 -g ingress -M -s /usr/sbin/nol
FROM base AS api FROM base AS api
USER 10001:10002 USER 10001:10002
ENV API_BIND=0.0.0.0:3001 DATA_DIR=/data CHUBLOG_SSH_CONFIG=/auth/publishers.json INGRESS_REPO=/srv/ingress/content.git RUST_LOG=info ENV API_BIND=0.0.0.0:3001 DATA_DIR=/data CHUBLOG_SSH_CONFIG=/auth/publishers.json INGRESS_DIR=/srv/ingress/authors RUST_LOG=info
ENTRYPOINT ["chublog-api"] ENTRYPOINT ["chublog-api"]
FROM base AS ssh FROM base AS ssh
+1 -1
View File
@@ -1,2 +1,2 @@
[safe] [safe]
directory = /srv/ingress/content.git directory = /srv/ingress/authors/*
+4 -5
View File
@@ -7,8 +7,7 @@ chmod 755 /auth
chown 10002:10002 /srv/ingress chown 10002:10002 /srv/ingress
chmod 750 /srv/ingress chmod 750 /srv/ingress
chown 10003:10003 /db-data /db-logs chown 10003:10003 /db-data /db-logs
if [ ! -d /srv/ingress/content.git ]; then # Per-author repositories are created lazily by the authenticated SSH gateway.
setpriv --reuid=10002 --regid=10002 --clear-groups git init --bare --initial-branch=main --shared=group /srv/ingress/content.git mkdir -p /srv/ingress/authors
setpriv --reuid=10002 --regid=10002 --clear-groups git -C /srv/ingress/content.git config receive.denyNonFastForwards true chown 10002:10002 /srv/ingress/authors
setpriv --reuid=10002 --regid=10002 --clear-groups git -C /srv/ingress/content.git config receive.denyDeletes true chmod 750 /srv/ingress/authors
fi
+46 -8
View File
@@ -1,12 +1,21 @@
# 后端 API # 后端 API
全部正文使用 JSON。同源 `Origin` 必须等于 `PUBLIC_ORIGIN`,例如 `https://blog.example.com`。响应含 `Cache-Control: no-store`。登录 Cookie 为 `HttpOnly; SameSite=Strict; Secure`,仅 localhost HTTP 开发场景省略 Secure。 [返回项目首页](../README.md)
## 请求约定
- 全部正文使用 JSON。
- 同源 `Origin` 必须等于 `PUBLIC_ORIGIN`,例如 `https://blog.example.com`。
- 响应含 `Cache-Control: no-store`。
- 登录 Cookie 为 `HttpOnly; SameSite=Strict; Secure`,仅 localhost HTTP 开发场景省略 Secure。
## 接口一览
| 方法 | 路径 | 权限 / 请求 | | 方法 | 路径 | 权限 / 请求 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/api/health` | 公共,API 存活,不代表数据库或发布同步健康 | | GET | `/api/health` | 公共,API 存活,不代表数据库或发布同步健康 |
| GET | `/api/posts` | 公共,`{commit,posts:[metadata]}`,仅已发布,日期倒序 | | GET | `/api/posts` | 公共,`{commit,posts:[metadata]}`,仅已发布,日期倒序 |
| GET | `/api/posts/:slug` | 公共,`{meta,markdown,assets:[{name,url}]}`,草稿及不存在均 404 | | GET | `/api/posts/:slug` | 公共,`{meta,markdown,format,author,article_id,assets:[{name,url}]}`,草稿及不存在均 404 |
| GET | `/api/assets/:slug/:name` | 公共,仅已发布文章的已验证图片 | | GET | `/api/assets/:slug/:name` | 公共,仅已发布文章的已验证图片 |
| GET | `/api/stats` | 公共,文章数、标签数、正文源字节数、启动以来文章请求数 | | GET | `/api/stats` | 公共,文章数、标签数、正文源字节数、启动以来文章请求数 |
| POST | `/api/auth/register` | 公共 + Origin;`{username,password}`,创建 reader;可配置关闭 | | POST | `/api/auth/register` | 公共 + Origin;`{username,password}`,创建 reader;可配置关闭 |
@@ -16,15 +25,44 @@
| PUT | `/api/account/ssh-key` | 登录 + Origin + CSRF;`{public_key}`,替换自己的唯一 Ed25519 或 RSA 公钥(RSA 2048–8192 位) | | PUT | `/api/account/ssh-key` | 登录 + Origin + CSRF;`{public_key}`,替换自己的唯一 Ed25519 或 RSA 公钥(RSA 2048–8192 位) |
| PUT | `/api/account/password` | 登录 + Origin + CSRF;`{old_password,new_password}`,成功后全部会话失效 | | PUT | `/api/account/password` | 登录 + Origin + CSRF;`{old_password,new_password}`,成功后全部会话失效 |
| GET | `/api/admin/users` | 管理员;返回用户启用状态、角色、前缀及公钥,不含哈希 | | GET | `/api/admin/users` | 管理员;返回用户启用状态、角色、前缀及公钥,不含哈希 |
| PATCH | `/api/admin/users/:username` | 管理员 + Origin + CSRF;`{enabled,role,prefixes}` | | PATCH | `/api/admin/users/:username` | 管理员 + Origin + CSRF;`{enabled,role}`;兼容可选的旧 `prefixes` 字段 |
| GET | `/api/admin/status` | 管理员;`{commit,total_posts,drafts,indexed_commit}` | | GET | `/api/admin/status` | 管理员;`{commit,total_posts,drafts,indexed_commit}` |
角色:`reader | publisher | admin`。网站管理员与数据库账号、OS 用户不是同一个系统。发布角色仍需已登记公钥和非空文章前缀才能使用 SSH。网站登录不产生 Git 访问凭证。 ## 权限与账户
用户名:3–32 位小写字母、数字、短横线,以字母开头;口令:12–1024 字节;前缀:2–64 位小写英文、数字、短横线且以短横线结尾,最多 8 个。注册不接受 `role` 等额外字段。公钥不能与别的用户重复。 - 角色:`reader | publisher | admin`。
- 网站管理员与数据库账号、OS 用户不是同一个系统。
- 发布角色仍需已登记公钥才能使用 SSH。
- 每个账户使用自己的仓库,旧前缀字段不再承担授权作用。
- 网站登录不产生 Git 访问凭证。
修改请求的头为 `Origin: https://blog.example.com` 和 `X-CSRF-Token: <登录返回的 csrf>`,并携带 Cookie。没有配置跨域 CORS,也没有浏览器本地持久存储令牌的要求。授权或停用用户会使该用户既有会话失效;再次登录后才能继续使用自己的账户接口。 用户名:3–32 位小写字母、数字、短横线,以字母开头;口令:12–1024 字节。旧前缀字段仅兼容接收,若传入仍按原格式校验。注册不接受 `role` 等额外字段。公钥不能与别的用户重复。
普通业务错误返回 `{ "error": "可读说明" }`;401 未登录/口令错误,403 Origin/CSRF/权限错误,404 不存在或非公开内容,409 用户名冲突,429 超过限流。Axum 的无效 JSON/字段结构错误使用框架的 400/415/422 响应,超过请求体上限返回 413。服务器内部错误不返回路径、凭据或数据库详情。 ## 修改请求
文章写入、上传、在线预览、后台发布/删除接口当前都不存在。文章修改和权限审批分开:管理员只能通过账号 API 批准身份与命名空间;文章在作者本机编辑,通过 SSH Git 发布。 - 修改请求的头为 `Origin: https://blog.example.com` 和 `X-CSRF-Token: <登录返回的 csrf>`,并携带 Cookie。
- 没有配置跨域 CORS,也没有浏览器本地持久存储令牌的要求。
- 授权或停用用户会使该用户既有会话失效;再次登录后才能继续使用自己的账户接口。
## 错误响应
- 普通业务错误返回 `{ "error": "可读说明" }`;401 未登录/口令错误,403 Origin/CSRF/权限错误,404 不存在或非公开内容,409 用户名冲突,429 超过限流。
- Axum 的无效 JSON/字段结构错误使用框架的 400/415/422 响应,超过请求体上限返回 413。
- 服务器内部错误不返回路径、凭据或数据库详情。
## 文章发布边界
文章写入、上传、在线预览、后台发布/删除接口当前都不存在。文章修改和权限审批分开:管理员只能通过账号 API 批准身份与发布资格;文章在作者本机编辑,通过 SSH Git 发布。
## 文章与资源
- 文章以作者仓库的 `index.txt` 为登记依据,每行三列:`文章ID posts/文件夹 正文文件名`。
- 站内 `:slug` 为 `作者名~文章ID`,例如 `alice~first`;索引 ID 不随文件夹或文件名变化。
- `markdown` 保留现有正文源字段(含 Frontmatter);`format` 为 `markdown` 或 `text`,纯文本不得当作 HTML 渲染。
- 未登记内容及草稿不通过公共文章/资源接口返回;删除索引项会自动撤回阅读内容并同步删除派生数据库记录。
## 同步状态
- `/api/admin/status` 的 `commit` 和 `indexed_commit` 为整个作者集合的版本摘要,不是单个作者的 Git HEAD。
- 作者各自的源提交记录在数据库 `blog_articles.git_commit` 和私有集合快照中。
- 数据库失败时 Git 接收与阅读仍可继续,索引在恢复后自动重建;站长审核暂未启用。
+4 -1
View File
@@ -6,7 +6,10 @@
## 决定 ## 决定
当前只做 Rust/Axum 后端。网站账户注册后为 reader;公钥登记和管理员发布授权分别完成。OpenSSH 使用固定 `AuthorizedKeysCommand` 动态读取独立授权文件,为每把允许的公钥返回绑定站内用户名的强制命令。OS 只有一个无交互权限的 git 账户,网站用户不能自行决定命令或仓库路径。 - 当前只做 Rust/Axum 后端。
- 网站账户注册后为 reader;公钥登记和管理员发布授权分别完成。
- OpenSSH 使用固定 `AuthorizedKeysCommand` 动态读取独立授权文件,为每把允许的公钥返回绑定站内用户名的强制命令。
- OS 只有一个无交互权限的 git 账户,网站用户不能自行决定命令或仓库路径。
接收仓库与正式内容仓库分属不同进程身份。pre-receive 在 Git quarantine 内验证全部候选文件、单提交历史和改动命名空间;API 再验证并导入独立正式仓库,从该 Git 生成已发布快照。ChuSQL 只保存可重建的公开文章元数据与标签,HTTP 读取不依赖数据库。 接收仓库与正式内容仓库分属不同进程身份。pre-receive 在 Git quarantine 内验证全部候选文件、单提交历史和改动命名空间;API 再验证并导入独立正式仓库,从该 Git 生成已发布快照。ChuSQL 只保存可重建的公开文章元数据与标签,HTTP 读取不依赖数据库。
+4 -1
View File
@@ -8,7 +8,10 @@
数据库服务与 Rust 驱动作为两个独立 Git 子模块维护。主仓库只提交远程地址、固定版本指针、初始化/构建配置,不提交依赖源码或编译产物。Cargo 引用初始化后的驱动路径,数据库从外部 checkout 构建并暂存到被忽略的 Docker 构建目录。 数据库服务与 Rust 驱动作为两个独立 Git 子模块维护。主仓库只提交远程地址、固定版本指针、初始化/构建配置,不提交依赖源码或编译产物。Cargo 引用初始化后的驱动路径,数据库从外部 checkout 构建并暂存到被忽略的 Docker 构建目录。
子模块默认使用 `https://github.com/TheBadRoger/ChuSQL.git` 与 `https://github.com/TheBadRoger/ChuSQL-RustDriver.git`。初始化脚本在连接、访问或固定提交获取失败时,分别回退到 `https://repo.chunana.cn/ChuNa7a/ChuSQL.git` 与 `https://repo.chunana.cn/ChuNa7a/ChuSQL-RustDriver.git`。所有远程来源均使用 HTTPS,不提供 SSH 来源选项;空仓库留下的半初始化状态也可恢复。回退仍保持固定提交,不自动跟随远程最新分支。 - 子模块默认使用 `https://github.com/TheBadRoger/ChuSQL.git` 与 `https://github.com/TheBadRoger/ChuSQL-RustDriver.git`。
- 初始化脚本在连接、访问或固定提交获取失败时,分别回退到 `https://repo.chunana.cn/ChuNa7a/ChuSQL.git` 与 `https://repo.chunana.cn/ChuNa7a/ChuSQL-RustDriver.git`。
- 所有远程来源均使用 HTTPS,不提供 SSH 来源选项;空仓库留下的半初始化状态也可恢复。
- 回退仍保持固定提交,不自动跟随远程最新分支。
服务器上的 Git 地址覆盖、操作系统账户和 SSH 配置由服务器自己管理,项目不配置 localhost 映射或固定 deploy 用户。Docker 构建前使用同一初始化脚本,继承运行者现有 Git 配置。开发者仍可以显式使用已有文件路径,此时文件协议仅对该次初始化命令启用,不设置全局许可。 服务器上的 Git 地址覆盖、操作系统账户和 SSH 配置由服务器自己管理,项目不配置 localhost 映射或固定 deploy 用户。Docker 构建前使用同一初始化脚本,继承运行者现有 Git 配置。开发者仍可以显式使用已有文件路径,此时文件协议仅对该次初始化命令启用,不设置全局许可。
@@ -0,0 +1,38 @@
# ADR-003:按作者隔离仓库,以索引登记文章
## 状态与日期
已接受,2026-10-16。替代 ADR-001 中共享仓库、slug 前缀隔离与固定正文路径的决定;唯一 Git 写入口、SSH 双重授权和账户存储现状不变。
## 决定
- 每位作者拥有 `/srv/ingress/authors/<用户名>.git`,API 在私有数据卷按作者分别保存 Git 缓存。
- 客户端仍只使用 `content.git` 别名,SSH 强制命令按服务器公钥绑定的身份选定真实仓库,不接受客户端指定路径。
- Git 全局信任范围仅限受控 `/srv/ingress/authors/*` 目录,以支持跨 UID 本地读取,不信任其他目录。
- 仓库首次访问时自动创建,作者无须服务器 shell 或手动建裸仓库。
- 不同仓库不共享对象库或历史。
- 根目录 `index.txt` 使用 UTF-8 三列文本,每行 `文章ID posts/文件夹 正文文件名`,空格或 Tab 分隔,无表头,允许空行。
- 每作者 ID 唯一、文件夹不重复,正文为指定的 txt/md/markdown 文件,元信息保留 YAML Frontmatter,slug 可省略。
- 站内 ID 为 `用户名~文章ID`,两部分均不能含 `~`,避免重名与歧义;目录或正文改名不改变身份。
- pre-receive 在 Git quarantine 中检查完整候选树、单个线性提交、索引引用、白名单、文件模式、文本和图片内容与配额。
- 接收层能够拒绝非法文件,故整次拒绝非法推送,不静默过滤后制造与作者提交不同的服务端分支。
- 未登记目录和未选中的正文不导入阅读内容及数据库,但仍受校验和存储配额限制,不能作为隐藏载荷通道。
- 只作为资源导入所选正文引用的已验证图片。
- API 再次验证各作者候选内容,构建私有集合快照及公开阅读快照。
- 数据库 `blog_articles` 保存有效登记的作者、本地 ID、正文路径、格式、元信息和源提交;公开检索继续使用 `blog_posts/blog_tags`。
- 索引条目移除后撤回正文和图片入口,并在数据库可用时事务重建派生记录。
- 数据库失败不阻止已导入文章阅读,恢复后自动对账重建。
- 集合版本是作者提交映射的摘要,单个作者 Git 提交仍单独保存。
## 取舍
索引是发布登记依据,不是远程代码执行配置。路径和文件名不允许空格、隐藏段或任意嵌套,暂不支持 SVG、HTML、脚本、配置或证书文件。文本中的 PEM 标记直接拒绝,包括代码块;这有意比一般技术博客严格,不能据此保证识别任意编码或伪装的秘密。
单作者 Git 历史仍保留自己的已删内容,移除索引不会释放历史占用;硬磁盘配额和管理员离线维护仍必要。站点总导入配额可能使通过单作者检查的推送等待处理,Git 接收不等于内容已导入或数据库已同步。当前按完整集合重建派生表,未来账户、互动、审核和站长管理状态必须用独立持久表,不能随着索引重建丢失。
旧共享仓库需要停机备份、按所有权导出最新文件、建立无共享历史的个人仓库,并重新生成索引。不会自动猜测所有权或清除旧数据;迁移方式见[运维与迁移](../operations.md)。已经分发的旧历史无法收回,旧 URL 的重定向尚未实现。
站长审核仅列入 TODO;本轮校验通过且状态为 published 的登记文章自动公开。后续应在索引导入与公开发布之间加入审核,审核未通过的修改不能替换原已通过版本。
+11
View File
@@ -0,0 +1,11 @@
# 架构决策
[返回项目首页](../../README.md)
| 决策 | 内容与适用范围 |
| --- | --- |
| [ADR-001](0001-git-only-publishing.md) | Git 唯一写入口与身份隔离;共享仓库方案已由 ADR-003 替代 |
| [ADR-002](0002-external-dependencies.md) | 外部依赖子模块、固定提交与 HTTPS 回退 |
| [ADR-003](0003-author-repositories-and-manifest.md) | 作者独立仓库与文章清单 |
旧决策保留其历史背景;当前作者隔离与文章索引规则以 ADR-003 为准。
+53
View File
@@ -0,0 +1,53 @@
# 部署
[返回项目首页](../README.md)
## 构建与启动
- 部署基于 Debian 13。
- `scripts/deploy.sh` 会使用同一个依赖初始化脚本,自动按[依赖源顺序](development.md)进行 HTTPS 回退,再构建外部数据库与 Docker 镜像;沿用运行者现有的 Git 配置,不在项目内覆盖服务器环境。
```sh
scripts/deploy.sh
```
## 首次部署
先按[开发与依赖](development.md)初始化子模块,再执行以下同架构构建步骤。使用 `scripts/deploy.sh` 时,也须先准备 `.env`。
```sh
# ChuSQL 尚未编译时,先按其项目说明构建 Rust 存储库、server 与 bootstrap。
python3 scripts/prepare-deploy.py --build-database
cp .env.example .env
chmod 600 .env
# 从标准输入输入管理员口令,Ctrl-D 结束;不要把明文写进命令行历史。
./deploy/bin/chublog-api hash-password
# 将输出以单引号包裹写入 .env 的 ADMIN_PASSWORD_HASH;同时配置域名及两份不同的随机 DB 口令。
docker compose --env-file .env -f deploy/compose.yaml up -d --build
# db 启动就绪后,创建 chublog 库和非超级用户 chublog_index;失败时检查日志后重试。
docker compose --env-file .env -f deploy/compose.yaml run --rm db-init
```
部署入口启动服务后,首次仍需执行上面的 `db-init` 命令;入口不会在数据库启动竞态中静默吞掉初始化错误。
## 构建选项与平台
构建时若 Debian 官方下载源较慢,可在 `.env` 配置 `DEBIAN_MIRROR=mirrors.ustc.edu.cn`,仍使用 Debian 签名校验。
- `prepare-deploy.py` 默认从数据库子模块读取编译产物;`--build-database` 会先构建其 Rust 存储库、Haskell server 和 bootstrap。
- 也可用 `--chusql-source /已有构建的/ChuSQL` 选择已有外部构建,没有产物会明确失败。
- 编译产物只暂存在被忽略的 `deploy/bin/`。
- 跨平台部署须先在目标 Debian/架构构建,不能复制 macOS、Windows 或其他架构二进制。
- 镜像不含编译器、源码、`.env` 或密码文件。
## 管理员初始化
首次启动从环境中的哈希创建管理员,后续账号与权限以持久文件为准,修改初始哈希不会覆盖已有账户。
## 域名与网络
- Caddy 根据 `SITE_ADDRESS` 提供 HTTPS 和 API 反向代理;`PUBLIC_ORIGIN` 必须与外部站点完全一致,不带结尾斜杠。
- HTTP 仅对 localhost 开发场景允许。
- 只需公网 TCP 80/443 和 Git SSH 2222(可改);主机管理 SSH 另行管理,数据库和 API 不映射宿主端口。
- 既有 UDP 7001 不受影响。
- 参考 [Caddy 请求体限制](https://caddyserver.com/docs/caddyfile/directives/request_body) 与 [反向代理文档](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)。
+74
View File
@@ -0,0 +1,74 @@
# 开发与依赖
[返回项目首页](../README.md)
```text
ChuBlog/
├── apps/api/
├── external/
│ ├── ChuSQL/ # Git 子模块,主仓库只记录版本指针
│ └── ChuSQL-RustDriver/ # Git 子模块
└── scripts/init-deps.py
```
## 获取代码
项目提供两个 HTTPS 仓库,任选其一克隆:
```sh
git clone https://github.com/TheBadRoger/ChuBlog.git
# 或:git clone https://repo.chunana.cn/ChuNa7a/ChuBlog.git
cd ChuBlog
python3 scripts/init-deps.py
```
## 依赖来源与回退
数据库和驱动均提供两个 HTTPS 源:
| 依赖 | GitHub | 自建仓库 |
| --- | --- | --- |
| ChuSQL | https://github.com/TheBadRoger/ChuSQL.git | https://repo.chunana.cn/ChuNa7a/ChuSQL.git |
| ChuSQL-RustDriver | https://github.com/TheBadRoger/ChuSQL-RustDriver.git | https://repo.chunana.cn/ChuNa7a/ChuSQL-RustDriver.git |
- 初始化先尝试 GitHub HTTPS,连接、访问或固定提交获取失败时回退自建 HTTPS,无需 SSH 身份。
- 已正确初始化的固定版本直接复用,有未提交修改时拒绝覆盖;回退不改为任意最新分支。
- 项目不设置全局 `insteadOf`、localhost 映射、OS 用户或服务器 SSH 配置。
## 使用本地依赖
开发电脑仍可显式选择已有本地来源(仅开发覆盖参数,不是服务器默认流程);只读取已提交版本:
```sh
python3 scripts/init-deps.py --database-repo ../ChuSQL --driver-repo ../ChuSQLDriver
```
- 子模块固定到已提交版本;源仓库必须包含这些提交。
- 依赖源码和构建产物不进入博客仓库,更新依赖时只提交 `.gitmodules`/配置及子模块指针。
- 不要对主仓库使用 `git add --force` 重新收录依赖文件。
## 开发检查
```sh
cargo test --all-targets
cargo fmt --check
cargo clippy --all-targets -- -D warnings
```
## 联调
```sh
python3 scripts/prepare-deploy.py --build-database
python3 scripts/smoke.py
python3 scripts/smoke.py --key-type rsa
```
- 联调使用独立随机命名的 Compose 项目、本机 18080/18443/12222 端口,测试结束清理自己创建的容器与卷。
- 覆盖真实 SSH 身份与授权、作者间 Git 对象隔离、非法推送、索引删除、ChuSQL 元信息创建/删除、发布撤回、断库阅读、重启与撤权。
- 该测试会拉取基础镜像,需要 Docker 可用。
## 历史清理后的协作
- 驱动的旧源码副本已从主仓库可达历史移除,后续只保留外部子模块配置。
- 历史重写后,已有克隆建议重新克隆并初始化依赖;不要把旧 main 合并回来,否则可能重新引入已删除的依赖历史。
- 自建服务器的不可达对象清理与其他人的旧克隆需要分别处理,GitHub 缓存和旧提交直链的回收由平台管理。
+48
View File
@@ -0,0 +1,48 @@
# 运维与迁移
[返回项目首页](../README.md)
## 持久化、故障和恢复
| 持久卷 | 内容 |
| --- | --- |
| `content` | 私有 Git 缓存 `authors/<用户名>/content.git`、`collection.json`、`users.json`、公开 `published.json` |
| `authorization` | 发布授权与公钥 |
| `ingress` | 作者接收仓库 `authors/<用户名>.git` |
| `ssh-host-keys` | SSH 服务器身份 |
| `database` / `db-logs` | 数据库数据 / 数据库日志 |
应用日志输出 stdout,由 Docker 分卷轮换,不记录口令、会话或私钥。
## 数据库故障与索引重建
- 数据库索引失败不会修改作者 Git 或阻塞已导入文章的 HTTP 阅读;新集合会触发同步,失败至少间隔 30 秒重试,并定期检查索引是否丢失。
- `blog_articles` 保存全部有效登记文章(含草稿)的作者、本地 ID、正文路径、格式、源提交和元信息;`blog_posts/blog_tags` 保留公开检索索引。
- 恢复数据库后可自动重建;离线命令为 `chublog-api rebuild-index`(必须停止同一数据卷上的 API 进程后运行,防止并发),或等待在线自动重试。
- `indexed_commit:null` 表示尚无已确认可用的索引,旧值表示尚未追平当前内容。
- 重建上述派生表使用 ChuSQL 事务;数据库不可用时保留阅读快照,恢复后重建。
## 备份与恢复
- 备份必须同时包含正式 Git、用户文件、授权卷、SSH 主机密钥;备份包含密码哈希,须加密并限制权限。
- 接收 Git 可额外镜像备份。
- 恢复时先停止写入,从同一备份恢复各卷,再启动;不要只恢复 ChuSQL,也不要直接向正式仓库推送。
- 应用启动加载私有集合快照,并继续从各作者 Git 校验导入。
- 丢失派生快照时,可在停机并保留备份后移走损坏的 `collection.json`,由各作者接收仓库重新导入;不要丢失作者仓库或账户授权。
- 进程重启会注销所有会话。
本实现对文件写入执行原子替换与 fsync,Git 引用采用受控更新;磁盘故障、主机断电和备份恢复仍需真实环境演练。不要对含正式数据的项目执行 `docker compose down -v`。
## 从旧共享仓库迁移
这是不兼容的存储与 URL 变更,不能直接把旧共享 Git 历史提供给新的个人仓库。先停机并加密备份全部卷,再完成以下操作:
1. 从旧共享仓库导出各作者最新的文章文件(含草稿),按原发布前缀及管理员确认的所有权分给作者;不要复制 `.git` 或旧提交历史。
2. 管理员离线将 `/auth/publishers.json` 的 `repository` 改为 `/srv/ingress/authors`(或自定义 `INGRESS_DIR`),保留用户和公钥授权。新版本 init 服务会创建该根目录。
3. 将旧 API 数据卷根目录的 `content.git` 移入只对管理员可见的备份位置;旧 `published.json` 同样备份。旧 SSH 共享仓库不再供服务使用,应移出接收卷离线保管。不要删除备份或清空用户数据。
4. 启动新服务。每位作者重新 clone 自己的空仓库,复制自己的导出文件,创建三列 `index.txt`,整理成一个首次提交并推送。旧 `slug` 如保留,须与索引 ID 一致。
5. 核对草稿隔离、所有文章、数据库索引和新的 `作者名~ID` URL 后再恢复对外服务。旧版本已被其他人克隆的内容无法收回。
- 已有授权配置仍指向旧仓库时,新版本拒绝启动;不会自动重新分发旧历史。
- 原链接到新 ID 的重定向尚未实现,迁移时需另行安排。
- 若自定义 `INGRESS_DIR`,需同步调整镜像内 `deploy/gitconfig` 的受信目录范围;默认仅信任 `/srv/ingress/authors/*`,不使用全局 `safe.directory=*`。
+107
View File
@@ -0,0 +1,107 @@
# 账户授权与文章发布
[返回项目首页](../README.md)
## 用户与发布授权
完整接口见 [API 文档](API.md)。
1. 注册:`POST /api/auth/register`,正文 `{ "username": "alice", "password": "至少12字节的口令" }`。默认角色 `reader`,绝不接受客户端指定角色。`ALLOW_REGISTRATION=false` 可关闭注册。
2. 登录:`POST /api/auth/login`,相同字段。浏览器保存 HttpOnly Cookie;返回 `csrf`。所有修改请求必须携带站点 `Origin` 和 `X-CSRF-Token`。
3. 本地生成 `ssh-keygen -t ed25519` 或 `ssh-keygen -t rsa -b 3072`;登录后 `PUT /api/account/ssh-key` 提交 `{ "public_key": "ssh-ed25519 AAAA..." }`,只交公钥。RSA 使用标准 `ssh-rsa AAAA...` 公钥文本,支持 2048–8192 位;实际认证使用 RSA/SHA-256 或 RSA/SHA-512,不启用旧的 SHA-1 签名。见 [OpenSSH 说明](https://www.openssh.com/txt/release-8.8)。
4. 管理员登录后调用 `PATCH /api/admin/users/alice`,正文 `{ "enabled": true, "role": "publisher" }`。服务器按公钥绑定的用户分配独立仓库。
5. 此时 SSH 才接受该用户的密钥;`reader`、停用用户、未绑密钥的用户均不能访问 Git。管理员也需登记公钥才能发布。旧 `prefixes` 字段仅为兼容保留,不再用于文章隔离。
- 授权与密钥独立:知道服务器地址、有网站账户、上传了公钥都不足以推送。
- 设回 `reader` 或 `enabled:false` 会撤回发布授权,并使现有网站会话失效。
- 禁止管理员停用自己或取消自己的管理角色。
## 本地 Git 工作流
- 先通过可信渠道核对服务器 Ed25519 主机密钥指纹;不要禁用主机密钥检查。
- 主机密钥保存在独立持久卷,重建容器不会更换。
- 所有发布者使用同一个 OS 账户 `git`,身份由对应公钥的强制命令绑定,不由 Git author/email 决定。
```sh
# ~/.ssh/config
Host chublog
HostName blog.example.com
Port 2222
User git
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
# 无论作者是谁,别名都是 content.git;服务端按认证身份映射个人仓库。
# 首次 clone 自动创建服务器裸仓库,作者无须自己创建裸仓库。
git clone chublog:content.git my-articles
cd my-articles
# 仓库已有内容时,每轮编辑前先 git pull --ff-only。
# 创建 index.txt 和正文,格式见下文。
git add index.txt posts/first
git commit -m 'Publish first article'
git push -u origin main
```
## 文章目录与清单
仓库结构示例:
```text
index.txt
posts/
└── first/
├── article.md
└── cover.png
```
`index.txt` 为 UTF-8 三列文本,以空格或 Tab 分隔,无表头,允许空行:
```text
first posts/first article.md
notes posts/my-notes notes.markdown
```
- 三列依次为**文章 ID、相对文件夹、正文文件名**。
- ID 在作者内唯一,支持 1–40 位小写字母、数字及中间短横线,另须避开保留名称。
- 目录必须是 `posts/<目录名>`,目录名遵循 slug 规则,最多 80 字节;文件名只允许 ASCII 字母、数字、短横线、下划线与点,不能以点开头或含 `..`。
- 同一目录不能重复登记;正文可以任意命名,扩展名为 `.md`、`.markdown` 或 `.txt`。
- 索引所指目录和正文必须存在,路径穿越和子目录正文被拒绝。
- 索引上限 64 KiB、1000 项。
## 正文与图片
- 正文沿用 YAML Frontmatter(参考[正文样例](../content/posts/welcome/index.md)),包含 `title/date/updated/status/tags/description`;`slug` 可省略,如填写则必须等于索引 ID。
- `.txt` 正文也需要 Frontmatter,API 通过 `format: text` 表明正文应作为纯文本展示;其他正文为 `format: markdown`。
- 图片置于同一目录,通过 `![说明](cover.png)` 引用。
## 新增、修改与撤回
| 作者操作 | 系统处理 |
| --- | --- |
| 新建正文并在索引新增一行 | 校验后导入文章,在 ChuSQL 创建作者、ID、正文路径、状态等元信息 |
| 修改正文或索引中的目录/文件名,保留 ID | 更新同一文章;站内 ID 不随目录移动而变化 |
| 删除索引中的一行 | 移除当前阅读内容、图片入口及对应数据库元信息,无需先删除本地正文 |
| 把索引清空 | 移除该作者所有当前文章,不影响其他作者 |
- 未被索引选中的正文及目录视为无效内容,不导入阅读快照或数据库;图片只有被选中正文引用才作为公开资源导入;仍计算仓库容量并执行安全校验。
- 删除正文但保留其索引项属于错误,整次推送会被拒绝。
- `status: draft` 可保留登记但不公开;物理删除文件和撤回文章均不会擦除 Git 历史。
## 提交与并发限制
- MVP 每次只接受**一个直接继承个人远端 main 的提交**,首次仅允许一个根提交。
- 禁止 merge、force push、删除 main、标签及其他分支;多次本地提交应先整理为一次提交,再变基到最新远端。
- 同一作者在多台电脑操作仍需处理并发冲突,不同作者互不争用同一个仓库锁。
## 作者隔离与文章地址
- 站内文章 ID 为 `作者名~文章ID`,例如 Alice 的 `first` 对应 `/api/posts/alice~first`;不同作者可以使用相同的本地 ID。
- 服务端分别保存每位作者的 Git 对象与历史,SSH 不能指定别人的仓库路径;作者无法通过 clone 或按提交哈希 fetch 读取其他作者草稿。
- 作者自己已克隆的历史仍可在本地保留,不能靠撤回收回副本。
## 发布状态
- API 自动同步,一般约 2 秒,数据库重建、失败重试或较多作者可能延迟。
- `GET /api/admin/status` 的 `commit` 是作者提交映射的集合摘要,不是任一作者 Git 提交;`indexed_commit` 表示数据库已同步的集合版本。
- Git 接受表示接收校验完成,不保证数据库已同步。
- 当前不做站长内容审核,审核计划见 [TODO](../TODO.md)。
+50
View File
@@ -0,0 +1,50 @@
# 内容限制与隔离边界
[返回项目首页](../README.md)
## 文件白名单
- 仅允许根目录 `index.txt` 和 `posts/<目录>/<文件>` 内的 `.txt/.md/.markdown/.png/.jpg/.jpeg/.webp` 普通文件;扩展名使用小写。
- 拒绝证书扩展名、隐藏文件、`.gitignore`、脚本、配置、符号链接、可执行位、子模块和其他类型。
- 文本中的 PEM 证书/密钥标记也会拒绝。
- Git 接收层能直接拦截,因此非白名单内容使整次推送失败,不静默改变作者提交。
## 容量配额
- 每个文本文件上限 256 KiB;图片单张 2 MiB、每目录最多 12 张、尺寸 4096×4096;每个目录合计 6 MiB。
- 每位作者最多 1000 个目录、14001 个文件、原始文件合计 64 MiB;全站导入快照另限 64 MiB、1000 篇,超出时等待管理员处理。
## Git 接收限额
- Git 输入 pack 上限 8 MiB;全部作者接收仓库合计达到 256 MiB 后拒绝新 SSH 会话,需要管理员离线维护。
- 此阈值是应用检查,生产还应为 Docker 数据盘配置真实文件系统配额。
## 正文解析
- 使用成熟 Markdown 解析器,拒绝原始 HTML、危险链接协议、外链图片、控制符和双向隐藏字符;代码块是惰性文本,绝不执行 MDX、JS、仓库脚本、构建任务或上传内容。
## SSH 命令与密钥
- 支持 Ed25519 和 2048–8192 位 RSA 公钥,拒绝弱密钥、负数/畸形 RSA 参数及额外二进制数据;拒绝把 `authorized_keys` 选项当作公钥上传。
- SSH 只接受精确的 `git-receive-pack 'content.git'` / `git-upload-pack 'content.git'`,没有 shell、SCP/SFTP、TTY、转发、隧道、用户环境变量。
## 进程与存储隔离
- 接收仓库由 UID 10002 管理;API 为独立 UID 10001,正式仓库/用户密码文件不挂载给 SSH 容器;SSH 只读授权文件和只读固定 hook。
- 接收内容再次验证后才导入 API 自己的按作者隔离的 Git 缓存与阅读快照。
## HTTP 与账户限额
- HTTP 请求体 8 KiB;注册/登录合计全局 10 次/分钟,修改操作全局 60 次/分钟;密码哈希并发最多 2。
- 1000 用户、1000 个有效会话上限,一小时固定会话期限。
- 全局限制偏保守,后续可细化 IP/账户限制以改善公平性。
## 统计与隐私
- 统计不收集 IP/访客身份:公开文章数、标签数、Markdown 字节数、启动以来文章 API 请求数。
- 请求数不是独立访客数,重启后清零。
## 前端接入
- 服务器只返回 JSON Markdown 与图片;后续前端必须把元数据当文本输出,并继续禁用原始 HTML、MDX和危险链接,不能将接口字段直接作为 HTML 执行。
- 隔离不能替代 Git/OpenSSH/解码库的持续安全更新。
+63 -11
View File
@@ -72,6 +72,25 @@ with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary:
envfile.chmod(0o600) envfile.chmod(0o600)
project = 'chublog-test-' + secrets.token_hex(4) project = 'chublog-test-' + secrets.token_hex(4)
compose = ['docker', 'compose', '--env-file', str(envfile), '-p', project, '-f', str(ROOT / 'deploy/compose.yaml')] compose = ['docker', 'compose', '--env-file', str(envfile), '-p', project, '-f', str(ROOT / 'deploy/compose.yaml')]
def database_articles():
# Query the private DB from inside its container; no host database port is opened.
probe = r'''set -eu
exec 3<>/dev/tcp/127.0.0.1/7777
printf '%s\n' '{"method":"hello","protocol":1}' >&3
read -r reply <&3
printf '%s\n' "{\"method\":\"login\",\"user\":\"root\",\"password\":\"${CHUSQL_ROOT_PASSWORD}\"}" >&3
read -r reply <&3
printf '%s\n' '{"method":"query","sql":"USE chublog"}' >&3
read -r reply <&3
printf '%s\n' '{"method":"query","sql":"SELECT id, author, article_id, path FROM blog_articles"}' >&3
read -r reply <&3
printf '%s\n' "$reply"
'''
result = run(compose + ['exec', '-T', 'db', 'bash', '-c', probe], capture_output=True)
response = json.loads(result.stdout)
assert response['status'] == 'result', response
return response['rows']
try: try:
run(compose + ['up', '-d', '--build']) run(compose + ['up', '-d', '--build'])
admin = Client() admin = Client()
@@ -95,6 +114,8 @@ with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary:
article.parent.mkdir(parents=True) article.parent.mkdir(parents=True)
original = (ROOT / 'content/posts/welcome/index.md').read_text().replace('slug: "welcome"', 'slug: "alice-first"') original = (ROOT / 'content/posts/welcome/index.md').read_text().replace('slug: "welcome"', 'slug: "alice-first"')
article.write_text(original) article.write_text(original)
manifest = local / 'index.txt'
manifest.write_text('alice-first posts/alice-first index.md\n')
def commit(): def commit():
run(['git', '-C', str(local), 'add', '.']) run(['git', '-C', str(local), 'add', '.'])
@@ -109,23 +130,26 @@ with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary:
push(False) push(False)
admin.call('PATCH', '/api/admin/users/alice', {'enabled': True, 'role': 'publisher', 'prefixes': ['alice-']}) admin.call('PATCH', '/api/admin/users/alice', {'enabled': True, 'role': 'publisher', 'prefixes': ['alice-']})
push() push()
wait_for(lambda: Client().call('GET', '/api/posts/alice-first')['meta']['slug'] == 'alice-first') wait_for(lambda: Client().call('GET', '/api/posts/alice~alice-first')['meta']['slug'] == 'alice~alice-first')
wait_for(lambda: (lambda s: s['indexed_commit'] == s['commit'])(admin.call('GET', '/api/admin/status')), timeout=120) wait_for(lambda: (lambda s: s['indexed_commit'] == s['commit'])(admin.call('GET', '/api/admin/status')), timeout=120)
print('PASS: registration, independent grant, real SSH push, publication, real ChuSQL index') assert database_articles() == [['alice~alice-first', 'alice', 'alice-first', 'posts/alice-first/index.md']]
print('PASS: registration, independent grant, real SSH push, publication, real ChuSQL metadata')
ssh = ['ssh', '-i', str(tmp / 'key'), '-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', f'UserKnownHostsFile={tmp / "known_hosts"}', '-p', '12222', 'git@localhost'] ssh = ['ssh', '-i', str(tmp / 'key'), '-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', f'UserKnownHostsFile={tmp / "known_hosts"}', '-p', '12222', 'git@localhost']
result = subprocess.run(ssh + ['id'], capture_output=True) result = subprocess.run(ssh + ['id'], capture_output=True)
assert result.returncode != 0, 'SSH shell command unexpectedly succeeded' assert result.returncode != 0, 'SSH shell command unexpectedly succeeded'
# Invalid commits must not advance the remote branch. Reset only this disposable local test clone. # Invalid commits must not advance the remote branch. Reset only this disposable local test clone.
for kind in ['script', 'symlink', 'foreign', 'oversize', 'pack']: for kind in ['script', 'symlink', 'manifest', 'certificate', 'gitignore', 'oversize', 'pack']:
if kind == 'script': if kind == 'script':
article.write_text(original + '\n<script>alert(1)</script>\n') article.write_text(original + '\n<script>alert(1)</script>\n')
elif kind == 'symlink': elif kind == 'symlink':
article.unlink() article.unlink()
article.symlink_to('/etc/passwd') article.symlink_to('/etc/passwd')
elif kind == 'foreign': elif kind == 'manifest':
other = local / 'posts/bob-first/index.md' manifest.write_text('alice-first posts/missing index.md\n')
other.parent.mkdir(parents=True) elif kind == 'certificate':
other.write_text(original.replace('alice-first', 'bob-first')) (article.parent / 'certificate.txt').write_text('-----BEGIN CERTIFICATE-----\ninvalid\n')
elif kind == 'gitignore':
(local / '.gitignore').write_text('*.pem\n')
elif kind == 'oversize': elif kind == 'oversize':
article.write_text(original + 'x' * (257 * 1024)) article.write_text(original + 'x' * (257 * 1024))
else: else:
@@ -135,20 +159,48 @@ with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary:
run(['git', '-C', str(local), 'reset', '--hard', 'origin/main'], capture_output=True) run(['git', '-C', str(local), 'reset', '--hard', 'origin/main'], capture_output=True)
push(False, 'main:refs/heads/other') push(False, 'main:refs/heads/other')
push(False, '--delete', 'main') push(False, '--delete', 'main')
print('PASS: arbitrary command, HTML, symlink, other-author edit, oversized article, extra branch and branch deletion rejected') print('PASS: arbitrary command, HTML, symlink, invalid index, disguised certificate, gitignore, oversize, branch and deletion rejected')
# A second authorized key maps the same remote alias to a separate object store.
bob = Client()
bob.call('POST', '/api/auth/register', {'username': 'bob', 'password': password})
bob.csrf = bob.call('POST', '/api/auth/login', {'username': 'bob', 'password': password})['csrf']
run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', str(tmp / 'bob-key')])
bob.call('PUT', '/api/account/ssh-key', {'public_key': (tmp / 'bob-key.pub').read_text().strip()})
admin.call('PATCH', '/api/admin/users/bob', {'enabled': True, 'role': 'publisher'})
bob_env = env.copy()
bob_env['GIT_SSH_COMMAND'] = env['GIT_SSH_COMMAND'].replace(str(tmp / 'key'), str(tmp / 'bob-key'))
bob_local = tmp / 'bob-articles'
run(['git', 'clone', 'git@localhost:content.git', str(bob_local)], env=bob_env, capture_output=True)
assert not (bob_local / 'posts/alice-first/index.md').exists(), 'Alice content leaked into Bob clone'
alice_commit = run(['git', '-C', str(local), 'rev-parse', 'HEAD'], capture_output=True).stdout.strip()
result = subprocess.run(['git', '-C', str(bob_local), 'fetch', 'origin', alice_commit], env=bob_env, capture_output=True)
assert result.returncode != 0, 'Bob fetched Alice commit by hash'
result = subprocess.run(['git', 'ls-remote', 'git@localhost:alice.git'], env=bob_env, capture_output=True)
assert result.returncode != 0, 'Bob selected another repository path'
print('PASS: separate author clone, inaccessible foreign commit and forbidden repository selection')
# Removing only an index entry deletes the live article, while retaining its files.
manifest.write_text('')
commit()
push()
wait_for(lambda: Client().call('GET', '/api/stats')['published_posts'] == 0)
wait_for(lambda: (lambda s: s['indexed_commit'] == s['commit'])(admin.call('GET', '/api/admin/status')), timeout=120)
Client().call('GET', '/api/posts/alice~alice-first', expected=404)
assert database_articles() == [], 'Index removal did not delete database metadata'
print('PASS: index-only deletion removes HTTP article and database metadata')
manifest.write_text('alice-first posts/alice-first index.md\n')
article.write_text(original.replace('status: "published"', 'status: "draft"')) article.write_text(original.replace('status: "published"', 'status: "draft"'))
commit() commit()
push() push()
wait_for(lambda: Client().call('GET', '/api/stats')['published_posts'] == 0) wait_for(lambda: Client().call('GET', '/api/stats')['published_posts'] == 0)
Client().call('GET', '/api/posts/alice-first', expected=404) Client().call('GET', '/api/posts/alice~alice-first', expected=404)
article.write_text(original) article.write_text(original)
commit() commit()
push() push()
wait_for(lambda: Client().call('GET', '/api/stats')['published_posts'] == 1) wait_for(lambda: Client().call('GET', '/api/stats')['published_posts'] == 1)
run(compose + ['stop', 'db']) run(compose + ['stop', 'db'])
assert Client().call('GET', '/api/posts/alice-first')['meta']['slug'] == 'alice-first' assert Client().call('GET', '/api/posts/alice~alice-first')['meta']['slug'] == 'alice~alice-first'
run(compose + ['restart', 'api']) run(compose + ['restart', 'api'])
wait_for(lambda: Client().call('GET', '/api/posts/alice-first')['meta']['slug'] == 'alice-first') wait_for(lambda: Client().call('GET', '/api/posts/alice~alice-first')['meta']['slug'] == 'alice~alice-first')
admin.csrf = admin.call('POST', '/api/auth/login', {'username': 'admin', 'password': password})['csrf'] admin.csrf = admin.call('POST', '/api/auth/login', {'username': 'admin', 'password': password})['csrf']
admin.call('PATCH', '/api/admin/users/alice', {'enabled': True, 'role': 'reader', 'prefixes': []}) admin.call('PATCH', '/api/admin/users/alice', {'enabled': True, 'role': 'reader', 'prefixes': []})
article.write_text(original + '\nRevoked publisher\n') article.write_text(original + '\nRevoked publisher\n')