整理文档
This commit is contained in:
1 parent
06a3d8506b
commit
0e347aad9e
26 files changed
+1534
-503
No files matched your search
@@ -9,3 +9,4 @@ dist/
|
|||||||
/deploy/secrets/
|
/deploy/secrets/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
external
|
||||||
Generated
+60
-20
@@ -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"
|
||||||
|
|||||||
@@ -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 使用 ``,不允许外链图片。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 缓存和旧提交直链的回收由平台管理。
|
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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, ¤t).await?;
|
.keys()
|
||||||
if snapshot.commit != app.snapshot.read().await.commit {
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let snapshot = ssh::import_authors(&app.data, source, ¤t, &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
@@ -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, ¤t.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
@@ -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", "").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",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
welcome posts/welcome index.md
|
||||||
+1
-1
@@ -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
@@ -1,2 +1,2 @@
|
|||||||
[safe]
|
[safe]
|
||||||
directory = /srv/ingress/content.git
|
directory = /srv/ingress/authors/*
|
||||||
@@ -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
@@ -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 接收与阅读仍可继续,索引在恢复后自动重建;站长审核暂未启用。
|
||||||
@@ -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 读取不依赖数据库。
|
||||||
|
|
||||||
|
|||||||
@@ -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 的登记文章自动公开。后续应在索引导入与公开发布之间加入审核,审核未通过的修改不能替换原已通过版本。
|
||||||
@@ -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 为准。
|
||||||
@@ -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)。
|
||||||
@@ -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 缓存和旧提交直链的回收由平台管理。
|
||||||
@@ -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=*`。
|
||||||
@@ -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`。
|
||||||
|
- 图片置于同一目录,通过 `` 引用。
|
||||||
|
|
||||||
|
## 新增、修改与撤回
|
||||||
|
|
||||||
|
| 作者操作 | 系统处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 新建正文并在索引新增一行 | 校验后导入文章,在 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)。
|
||||||
@@ -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
@@ -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')
|
||||||
|
|||||||
Reference in new issue
Block a user