diff --git a/.gitignore b/.gitignore index 120402a..3678756 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ dist/ /deploy/secrets/ __pycache__/ *.pyc +external diff --git a/Cargo.lock b/Cargo.lock index 7885c60..3ec9843 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,10 +220,10 @@ dependencies = [ "rand", "serde", "serde_json", - "serde_yaml", "ssh-key", "tempfile", "tokio", + "toml", "tower", "tracing", "tracing-subscriber", @@ -1245,6 +1245,15 @@ dependencies = [ "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]] name = "serde_urlencoded" version = "0.7.1" @@ -1257,19 +1266,6 @@ dependencies = [ "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]] name = "sha2" version = "0.10.9" @@ -1484,6 +1480,47 @@ dependencies = [ "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]] name = "tower" version = "0.5.3" @@ -1598,12 +1635,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "valuable" version = "0.1.1" @@ -1757,6 +1788,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "zerocopy" version = "0.8.63" diff --git a/README.md b/README.md index a4f7756..cb1caec 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,21 @@ # 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/ -├── external/ -│ ├── ChuSQL/ # Git 子模块,主仓库只记录版本指针 -│ └── ChuSQL-RustDriver/ # Git 子模块 -└── scripts/init-deps.py -``` +| 文档 | 内容 | +| --- | --- | +| [开发与依赖](docs/development.md) | 获取代码、双 HTTPS 依赖源、开发检查与联调 | +| [部署](docs/deployment.md) | Debian 13、Docker Compose、首次初始化 | +| [文章发布](docs/publishing.md) | 账户授权、个人仓库、文章清单与 Git 工作流 | +| [API](docs/API.md) | HTTP 接口、认证、请求与错误响应 | +| [内容与隔离](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 -git clone https://github.com/TheBadRoger/ChuBlog.git -# 或:git clone https://repo.chunana.cn/ChuNa7a/ChuBlog.git -cd ChuBlog -python3 scripts/init-deps.py -``` - -数据库和驱动均提供两个 HTTPS 源: - -| 依赖 | GitHub | 自建仓库 | -| --- | --- | --- | -| ChuSQL | https://github.com/TheBadRoger/ChuSQL.git | https://repo.chunana.cn/ChuNa7a/ChuSQL.git | -| ChuSQL-RustDriver | https://github.com/TheBadRoger/ChuSQL-RustDriver.git | https://repo.chunana.cn/ChuNa7a/ChuSQL-RustDriver.git | - -初始化先尝试 GitHub HTTPS,连接、访问或固定提交获取失败时回退自建 HTTPS,无需 SSH 身份。已正确初始化的固定版本直接复用,有未提交修改时拒绝覆盖;回退不改为任意最新分支。项目不设置全局 `insteadOf`、localhost 映射、OS 用户或服务器 SSH 配置。 - -开发电脑仍可显式选择已有本地来源(仅开发覆盖参数,不是服务器默认流程);只读取已提交版本: - -```sh -python3 scripts/init-deps.py --database-repo ../ChuSQL --driver-repo ../ChuSQLDriver -``` - -子模块固定到已提交版本;源仓库必须包含这些提交。依赖源码和构建产物不进入博客仓库,更新依赖时只提交 `.gitmodules`/配置及子模块指针。不要对主仓库使用 `git add --force` 重新收录依赖文件。 - - -开发检查: - -```sh -cargo test --all-targets -cargo fmt --check -cargo clippy --all-targets -- -D warnings -``` - -部署基于 Debian 13。`scripts/deploy.sh` 会使用同一个依赖初始化脚本,自动在上述两个 HTTPS 源之间回退,再构建外部数据库与 Docker 镜像;沿用运行者现有的 Git 配置,不在项目内覆盖服务器环境。 - -```sh -scripts/deploy.sh -``` - -首次部署先准备 `.env`;也可以手动执行以下同架构构建步骤: - -```sh -# ChuSQL 尚未编译时,先按其项目说明构建 Rust 存储库、server 与 bootstrap。 -python3 scripts/prepare-deploy.py --build-database -cp .env.example .env -chmod 600 .env -# 从标准输入输入管理员口令,Ctrl-D 结束;不要把明文写进命令行历史。 -./deploy/bin/chublog-api hash-password -# 将输出以单引号包裹写入 .env 的 ADMIN_PASSWORD_HASH;同时配置域名及两份不同的随机 DB 口令。 -docker compose --env-file .env -f deploy/compose.yaml up -d --build -# db 启动就绪后,创建 chublog 库和非超级用户 chublog_index;失败时检查日志后重试。 -docker compose --env-file .env -f deploy/compose.yaml run --rm db-init -``` - -部署入口启动服务后,首次仍需执行上面的 `db-init` 命令;入口不会在数据库启动竞态中静默吞掉初始化错误。 - -构建时若 Debian 官方下载源较慢,可在 `.env` 配置 `DEBIAN_MIRROR=mirrors.ustc.edu.cn`,仍使用 Debian 签名校验。 - -`prepare-deploy.py` 默认从数据库子模块读取编译产物;`--build-database` 会先构建其 Rust 存储库、Haskell server 和 bootstrap。也可用 `--chusql-source /已有构建的/ChuSQL` 选择已有外部构建,没有产物会明确失败。编译产物只暂存在被忽略的 `deploy/bin/`。跨平台部署须先在目标 Debian/架构构建,不能复制 macOS、Windows 或其他架构二进制。镜像不含编译器、源码、`.env` 或密码文件。首次启动从环境中的哈希创建管理员,后续账号与权限以持久文件为准,修改初始哈希不会覆盖已有账户。 - -Caddy 根据 `SITE_ADDRESS` 提供 HTTPS 和 API 反向代理;`PUBLIC_ORIGIN` 必须与外部站点完全一致,不带结尾斜杠。HTTP 仅对 localhost 开发场景允许。只需公网 TCP 80/443 和 Git SSH 2222(可改);主机管理 SSH 另行管理,数据库和 API 不映射宿主端口。既有 UDP 7001 不受影响。参考 [Caddy 请求体限制](https://caddyserver.com/docs/caddyfile/directives/request_body) 与 [反向代理文档](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)。 - -## 用户与发布授权 - -完整接口见 [docs/API.md](docs/API.md)。 - -1. 注册:`POST /api/auth/register`,正文 `{ "username": "alice", "password": "至少12字节的口令" }`。默认角色 `reader`,绝不接受客户端指定角色。`ALLOW_REGISTRATION=false` 可关闭注册。 -2. 登录:`POST /api/auth/login`,相同字段。浏览器保存 HttpOnly Cookie;返回 `csrf`。所有修改请求必须携带站点 `Origin` 和 `X-CSRF-Token`。 -3. 本地生成 `ssh-keygen -t ed25519` 或 `ssh-keygen -t rsa -b 3072`;登录后 `PUT /api/account/ssh-key` 提交 `{ "public_key": "ssh-ed25519 AAAA..." }`,只交公钥。RSA 使用标准 `ssh-rsa AAAA...` 公钥文本,支持 2048–8192 位;实际认证使用 RSA/SHA-256 或 RSA/SHA-512,不启用旧的 SHA-1 签名。见 [OpenSSH 说明](https://www.openssh.com/txt/release-8.8)。 -4. 管理员登录后调用 `PATCH /api/admin/users/alice`,正文 `{ "enabled": true, "role": "publisher", "prefixes": ["alice-"] }`。不同有效发布者的前缀不能重叠。 -5. 此时 SSH 才接受该用户的密钥;`reader`、停用用户、未绑密钥的用户均不能访问 Git。管理员也需显式分配前缀和公钥才能发布。 - -授权与密钥独立:知道服务器地址、有网站账户、上传了公钥都不足以推送。设回 `reader` 或 `enabled:false` 会撤回发布授权,并使现有网站会话失效。禁止管理员停用自己或取消自己的管理角色。 - -## 本地 Git 工作流 - -先通过可信渠道核对服务器 Ed25519 主机密钥指纹;不要禁用主机密钥检查。主机密钥保存在独立持久卷,重建容器不会更换。所有发布者使用同一个 OS 账户 `git`,身份由对应公钥的强制命令绑定,不由 Git author/email 决定。 - -```sh -# ~/.ssh/config -Host chublog - HostName blog.example.com - Port 2222 - User git - IdentityFile ~/.ssh/id_ed25519 - IdentitiesOnly yes - -# 首次发布,远端是空仓库时: -git init --initial-branch=main my-articles -cd my-articles -git remote add origin chublog:content.git -# 创建 posts/alice-first/index.md,规范参考 content/posts/welcome/index.md。 -# 样例 slug welcome 需要改成自己获授权的 alice-first。 -git add posts/alice-first -git commit -m 'Publish first article' -git push -u origin main - -# 已有内容时先 clone;每轮编辑前拉取最新内容。 -git clone chublog:content.git -cd content -git pull --ff-only -# 编辑 posts/alice-first/index.md -git add posts/alice-first -git commit -m 'Update article' -git push -``` - -MVP 每次只接受 **一个直接继承远端 main 的提交**,首次仅允许一个根提交。禁止 merge、force push、删除 main、标签及其他分支;本地有多次提交时先整理为单次提交,再拉取/变基到最新远端。发生并发冲突时,拉取并解决后重试。 - -`status: published` 发布,改为 `draft` 撤回,删除文章目录则删除当前版本。图片置于同一目录,Markdown 使用 `![说明](cover.png)`,不允许外链图片。API 在同步后读取新内容,一般约 2 秒;索引重建期间可能延迟到约 32 秒。`GET /api/admin/status` 查看已导入与已索引 commit;Git 接受只表示接收校验完成,尚不代表索引同步成功。 - -**授权发布者可以 clone 全部文章仓库及历史,包括其他发布者的草稿。** 草稿只对公开 HTTP 隔离,当前仓库不提供发布者之间的草稿保密;不要提交秘密或私钥。删除/撤回不能抹掉 Git 历史。 - -## 内容与隔离边界 - -- 唯一内容入口是 `posts//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 缓存和旧提交直链的回收由平台管理。 +规划:[当前范围与待办](TODO.md) · [原始规划](PLAN.md) diff --git a/TODO.md b/TODO.md index 8698f63..208f560 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,6 @@ # ChuBlog — 后续待办计划 -更新:2026-10-15。本文依据最新需求划分后续工作;`PLAN.md` 保留原始总体规划。当前只做后端,Git 是唯一文章写入入口。 +更新:2026-10-16。本文依据最新需求划分后续工作;`PLAN.md` 保留原始总体规划。当前只做后端,Git 是唯一文章写入入口。以下未勾选项均为待办,不代表已经实现。 ## 当前已实现 @@ -9,11 +9,11 @@ - [x] reader / publisher / admin 权限;注册不能自选权限,管理员单独审批发布权、停用用户,修改权限使已有会话失效。 - [x] 用户上传/轮换自己的 Ed25519/RSA 公钥,公钥存在与具有发布权分别检查。 - [x] OpenSSH 强制命令:仅固定仓库的 Git push/clone;禁用 shell、SFTP/SCP、TTY、代理/端口转发、隧道与用户环境。 -- [x] 发布者 slug 前缀授权,不允许重叠授权或修改其他命名空间。 +- [x] 每位作者独立 SSH 文章仓库与 Git 对象历史,公钥身份映射固定仓库别名,无法读取或改写他人仓库。 - [x] pre-receive 校验 Git quarantine 内的候选提交:单次一个线性提交、仅 main、禁止删除分支、强推、标签及其他分支。 - [x] Markdown Frontmatter、路径、文件模式、大小配额、图片类型/内容/尺寸与链接安全校验;拒绝原始 HTML、隐藏控制符、符号链接、可执行文件及额外文件。 -- [x] 独立 SSH 接收仓库与 API 正式仓库;从 Git 恢复公开快照,草稿不出现在公开 API/资源接口。 -- [x] 外部 `ChuSQL`/`ChuSQL-RustDriver` 子模块配置、GitHub HTTPS 默认来源及授权设备 SSH 优先/HTTPS 回退;ChuSQL 公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。 +- [x] 按作者隔离 SSH 接收仓库与 API 私有 Git 缓存;三列 `index.txt` 登记文章 ID、目录和正文文件名,增删条目自动同步阅读快照和 ChuSQL 元信息;未登记文件不导入,草稿不出现在公开接口。 +- [x] 外部 `ChuSQL`/`ChuSQL-RustDriver` 子模块配置、GitHub HTTPS 默认来源及授权设备 SSH 优先/HTTPS 回退;ChuSQL 文章登记元信息、公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。 - [x] Docker Compose、Caddy 反向代理、独立持久卷、容器资源限制、最小进程身份与网络隔离。 - [x] 后端/驱动自动测试、真实容器 SSH 联调脚本、运行/API/架构说明。 @@ -24,20 +24,65 @@ - [ ] 给 Docker 数据目录设置文件系统硬配额;用压缩炸弹、大量失败推送和连接洪泛验证空间、CPU、内存、超时及进程数边界。 - [ ] 对正式 Git、用户哈希、授权文件、SSH 主机密钥制定加密备份与保留策略,执行完整恢复演练。 - [ ] 验证断电/磁盘满时 Git 引用、快照、账户及权限文件的一致性;故障恢复后不保留过期发布权。 -- [ ] 在实际 ChuSQL 版本执行长期并发、进程崩溃恢复及索引故障注入验证;保持其派生索引定位。 +- [ ] 在实际 ChuSQL 版本执行长期并发、进程崩溃恢复及索引故障注入验证;文章索引保持可重建,后续账户、评论及互动数据作为独立持久数据备份恢复。 - [ ] 固定经过验证的镜像 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),敏感授权操作二次验证。 - [ ] 安全的管理员离线恢复/重置流程;用户密码找回需要先决定可信邮件或邀请渠道,禁止不验证身份的重置接口。 - [ ] 每用户多把公钥、指纹展示、单钥撤销、最后使用时间;保留每钥身份绑定。 - [ ] 在全局限流之上增加可信代理来源的 IP 与账户维度限制、失败退避,减轻公共认证桶被恶意占满的问题。 - [ ] 注册开放策略、邀请注册或邮件验证;保留注册与发布审批的权限分离。 - [ ] 持久化结构化审计,明确记录每次接受的 commit 对应站内用户与公钥指纹,并设置保留周期。当前 SSH 接收日志已有身份与 commit,API 导入日志只有 commit,尚无统一审计查询。 -- [ ] 发布者之间的草稿隔离需求确认:当前授权作者能 clone 全仓库及历史。如需作者之间保密,改用独立收稿仓库,再统一发布。 +- [ ] 为旧共享仓库迁移执行所有权核对、只导出最新文件的个人仓库重建及链接重定向;不得向作者分发旧共享历史。 +- [ ] 在索引导入与公开发布之间加入站长审核:新增文章及后续修改先成为待审核版本,MASTER 决定通过或驳回;未通过内容不得公开,修改审核期间保留上一已通过版本,明确索引删除/撤回语义、审计与作者状态查询。本轮只保留该待办,不启用审核。 ## 第三优先级:Astro + TypeScript 阅读前端 @@ -56,9 +101,9 @@ - [ ] 目录配额预警、安全 GC 与维护窗口;禁止在 SSH 请求里运行任意维护命令。 - [ ] 扩充现有 commit/行数对账为完整字段与标签对账;增加磁盘/登录失败/索引延迟/导入失败告警,以及受控恢复工具。 - [ ] API 分页与条件缓存:保留撤回后不可继续公开访问的语义。 -- [ ] 更完整的统计:先定义访问口径与隐私策略,再决定是否持久化每日聚合;当前请求计数不是独立访客数,重启清零。 +- [ ] 配合第二优先级的互动统计,落实访问口径、隐私策略、每日聚合和统计故障恢复。 - [ ] 外部资源链接检查、slug 重定向与图片元数据清理;不能因此在服务器执行作者提供的命令。 ## 后续再评估 -在线编辑/上传/预览、全文检索、评论、多副本和更复杂的角色。若引入在线编辑,应先明确其如何经过同一授权、校验与发布协调流程,不能在现有 Git 仓库上新增未经协调的第二个写入口。 +在线编辑/上传/预览、全文检索和多副本。评论、四级角色和精华功能已列入明确待办;HTML 富文本按上述后期计划评估。若引入在线编辑,应先明确其如何经过同一授权、校验与发布协调流程,不能在现有 Git 仓库上新增未经协调的第二个写入口。 diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index 53cf483..d55f3bb 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -15,7 +15,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" -serde_yaml = "0.9" +toml = "0.8.23" ssh-key = { version = "0.6.7", default-features = false, features = ["std"] } tokio = { version = "1", features = ["full"] } pulldown-cmark = "0.13" diff --git a/apps/api/src/accounts.rs b/apps/api/src/accounts.rs index b882ef5..32fe456 100644 --- a/apps/api/src/accounts.rs +++ b/apps/api/src/accounts.rs @@ -34,6 +34,7 @@ pub enum Role { pub struct Grant { pub enabled: bool, pub role: Role, + #[serde(default)] pub prefixes: Vec, pub ssh_key: Option, } @@ -507,6 +508,7 @@ pub async fn users(State(app): State>, headers: HeaderMap) -> ApiResult pub struct GrantInput { enabled: bool, role: Role, + #[serde(default)] prefixes: Vec, } pub async fn grant( @@ -517,9 +519,6 @@ pub async fn grant( ) -> ApiResult> { let actor = authorize(&app, &headers, true, true).await?; 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; if !accounts .grants @@ -535,22 +534,6 @@ pub async fn grant( if actor == name && (input.role != Role::Admin || !input.enabled) { 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 entry = grants .publishers diff --git a/apps/api/src/content.rs b/apps/api/src/content.rs index f979442..8459b71 100644 --- a/apps/api/src/content.rs +++ b/apps/api/src/content.rs @@ -1,6 +1,5 @@ use anyhow::{Context, Result, ensure}; use base64::{Engine, engine::general_purpose::STANDARD}; -use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use std::{ collections::BTreeMap, @@ -23,10 +22,15 @@ pub enum Status { Published, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct Metadata { pub title: String, + #[serde(default)] + pub cover: Option, + #[serde(default)] + pub created_at: String, + #[serde(default)] pub slug: String, pub date: String, pub updated: String, @@ -35,24 +39,51 @@ pub struct Metadata { pub description: String, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct Asset { pub name: String, pub data: String, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct Post { pub meta: Metadata, pub markdown: String, + #[serde(default)] + pub entry: String, + #[serde(default)] + pub pages: BTreeMap, pub assets: Vec, + #[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 commit: String, pub posts: BTreeMap, + #[serde(default)] + pub revisions: BTreeMap, + #[serde(default)] + pub pending: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct PendingIndex { + pub commit: String, + pub reason: String, } pub fn slug(value: &str) -> Result<&str> { @@ -75,51 +106,38 @@ pub fn slug(value: &str) -> Result<&str> { Ok(value) } -pub fn parse(markdown: String, assets: Vec) -> Result { - ensure!(markdown.len() <= MAX_MARKDOWN, "Markdown 超过 256 KiB"); - let normalized = markdown.replace("\r\n", "\n"); - let rest = normalized - .strip_prefix("---\n") - .context("缺少 YAML Frontmatter")?; - let (yaml, _) = rest.split_once("\n---\n").context("Frontmatter 未闭合")?; - let meta: Metadata = serde_yaml::from_str(yaml).context("Frontmatter 无效")?; - slug(&meta.slug)?; - ensure!( - !meta.title.trim().is_empty() && meta.title.len() <= 240 && meta.description.len() <= 1000, - "标题或摘要长度无效" - ); - 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!( - meta.tags.len() <= 12 - && meta - .tags - .iter() - .all(|t| !t.trim().is_empty() && t.len() <= 80), - "标签无效" - ); - ensure!(assets.len() <= 12, "最多 12 张图片"); - let mut names = std::collections::HashSet::new(); - let mut total = normalized.len(); - for asset in &assets { - ensure!(names.insert(&asset.name), "图片名称重复"); - let bytes = decode_asset(asset)?; - total += bytes.len(); - } - 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 stamp(post: &mut Post, previous: Option<&Post>, now: &str) { + post.meta.created_at = previous + .map(|p| p.meta.created_at.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| now.into()); + post.meta.date = previous + .map(|p| p.meta.date.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| { + if post.meta.status == Status::Published { + now.into() + } else { + String::new() + } + }); + let unchanged = previous.is_some_and(|p| { + p.meta.title == post.meta.title + && p.meta.description == post.meta.description + && p.meta.tags == post.meta.tags + && p.meta.cover == post.meta.cover + && p.meta.status == post.meta.status + && p.path == post.path + && p.pages == post.pages + && p.assets == post.assets + && p.markdown == post.markdown + && p.entry == post.entry + }); + post.meta.updated = if unchanged { + previous.unwrap().meta.updated.clone() + } else { + now.into() + }; } pub fn decode_asset(asset: &Asset) -> Result> { @@ -131,7 +149,7 @@ pub fn decode_asset(asset: &Asset) -> Result> { && name .bytes() .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, "图片超过大小限制"); @@ -261,19 +279,139 @@ pub async fn init(data: &Path) -> Result { Ok(repo) } -pub async fn scan(repo: &Path) -> Result { - let commit = String::from_utf8(git(repo, &["rev-parse", "refs/heads/main"], None).await?)? - .trim() - .to_owned(); - scan_at(repo, &commit).await +#[derive(Debug)] +pub enum Scan { + Ready(Snapshot), + Pending(String), +} +impl Scan { + pub fn ready(self) -> Result { + match self { + Self::Ready(snapshot) => Ok(snapshot), + Self::Pending(reason) => anyhow::bail!("索引待修复:{reason}"), + } + } } -pub async fn scan_at(repo: &Path, commit: &str) -> Result { - let commit = commit.to_owned(); - let entries = git(repo, &["ls-tree", "-rz", &commit], None).await?; - let mut grouped: BTreeMap, Vec)> = BTreeMap::new(); +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Manifest { + #[serde(default)] + articles: Vec, +} +#[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, + #[serde(default)] + pub cover: Option, + #[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> { + 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, + assets: Vec, + bytes: usize, +} + +pub async fn scan_at(repo: &Path, commit: &str) -> Result { + let entries = git(repo, &["ls-tree", "-rz", commit], None).await?; + let mut grouped: BTreeMap = BTreeMap::new(); + let mut index = None; let mut total = 0; + let mut count = 0; 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 (info, path) = entry.split_once('\t').context("Git 树格式错误")?; let info: Vec<_> = info.split_whitespace().collect(); @@ -282,46 +420,201 @@ pub async fn scan_at(repo: &Path, commit: &str) -> Result { "禁止符号链接及特殊文件" ); let parts: Vec<_> = path.split('/').collect(); + let is_index = path == "index.toml"; + if !is_index { + ensure!( + parts.len() == 3 && parts[0] == "posts", + "仅允许 index.toml 和 posts/文件夹/文件" + ); + slug(parts[1])?; + filename(parts[2])?; + } + let extension = path.rsplit('.').next().unwrap_or(""); + let text_file = matches!(extension, "txt" | "md" | "markdown"); ensure!( - parts.len() == 3 && parts[0] == "posts", - "仓库包含非文章路径" + is_index || text_file || matches!(extension, "png" | "jpg" | "jpeg" | "webp"), + "仅允许 txt、md、markdown、PNG/JPEG/WebP;禁止证书、配置及隐藏文件" ); - slug(parts[1])?; let size = String::from_utf8(git(repo, &["cat-file", "-s", info[2]], None).await?)? .trim() .parse::()?; - let limit = if parts[2] == "index.md" { + let limit = if is_index { + 65536 + } else if text_file { MAX_MARKDOWN } else { 2 * 1024 * 1024 }; ensure!(size <= limit, "文件超过大小限制"); + total += size; + ensure!(total <= MAX_SNAPSHOT, "作者仓库超过 64 MiB 配额"); let bytes = git(repo, &["cat-file", "blob", info[2]], None).await?; - total += bytes.len(); - ensure!(total <= MAX_SNAPSHOT, "内容仓库超过 64 MiB 配额"); - let group = grouped.entry(parts[1].into()).or_default(); - if parts[2] == "index.md" { - group.0 = Some(String::from_utf8(bytes)?); + if is_index { + index = Some(String::from_utf8(bytes).context("index.toml 必须为 UTF-8")); + continue; + } + 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 { - group.1.push(Asset { + let asset = Asset { name: parts[2].into(), 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, + grouped: &mut BTreeMap, + commit: &str, +) -> Result> { + use pulldown_cmark::{Event, Parser, Tag}; let mut posts = BTreeMap::new(); - for (key, (markdown, assets)) in grouped { - let post = parse(markdown.context("文章缺少 index.md")?, assets)?; - ensure!(post.meta.slug == key, "slug 与 Git 路径不一致"); - posts.insert(key, post); + for article in index { + let Some(folder) = grouped.remove(&article.folder) else { + continue; + }; + // 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()); + } + 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(Snapshot { commit, posts }) + Ok(posts) } pub async fn persist(data: &Path, snapshot: &Snapshot) -> Result<()> { let public = Snapshot { commit: snapshot.commit.clone(), + revisions: BTreeMap::new(), + pending: BTreeMap::new(), posts: snapshot .posts .iter() @@ -340,12 +633,7 @@ pub async fn persist(data: &Path, snapshot: &Snapshot) -> Result<()> { pub fn validate_markdown(body: &str, assets: &[Asset]) -> Result<()> { use pulldown_cmark::{Event, Parser, Tag}; - ensure!( - !body.chars().any(|c| c == '\0' - || (c.is_control() && !"\n\r\t".contains(c)) - || matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')), - "禁止控制符及双向隐藏字符" - ); + validate_text(body)?; for event in Parser::new_ext(body, pulldown_cmark::Options::all()) { match event { 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("mailto:") || 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!( @@ -364,7 +660,7 @@ pub fn validate_markdown(body: &str, assets: &[Asset]) -> Result<()> { ); } 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)), "图片必须引用同文章已验证的图片文件" ), _ => {} diff --git a/apps/api/src/index.rs b/apps/api/src/index.rs index 77c74fc..447200c 100644 --- a/apps/api/src/index.rs +++ b/apps/api/src/index.rs @@ -45,6 +45,30 @@ pub async fn rebuild(snapshot: &Snapshot) -> Result<()> { db.query("CREATE TABLE blog_tags (slug TEXT, tag TEXT, git_commit TEXT)") .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_posts").await?; 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 (") .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.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(")"); db.query(q.as_str()).await?; for tag in &meta.tags { @@ -70,6 +94,7 @@ pub async fn rebuild(snapshot: &Snapshot) -> Result<()> { db.query(q.as_str()).await?; } } + db.query("COMMIT").await?; db.close().await; Ok(()) } @@ -131,7 +156,23 @@ pub async fn consistent(snapshot: &Snapshot) -> Result { .filter(|p| p.meta.status == Status::Published) .map(|p| p.meta.tags.len()) .sum::() 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() && actual == expected && count diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index e24acfe..8d21c0b 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -85,7 +85,27 @@ async fn public_post( // Relaxed is sufficient: this independent counter does not synchronize other state. app.views.fetch_add(1, Ordering::Relaxed); 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::>()}), + 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::>(), "assets": post.assets.iter().map(|a| json!({"name": a.name, "url": format!("/api/assets/{key}/{}", a.name)})).collect::>()}), + )) +} +async fn page( + State(app): State>, + Path((key, name)): Path<(String, String)>, +) -> ApiResult> { + 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::>(), + "assets":post.assets.iter().map(|a| json!({"name":a.name,"url":format!("/api/assets/{key}/{}",a.name)})).collect::>() }), )) } async fn asset( @@ -132,7 +152,7 @@ async fn admin_status(State(app): State>, headers: HeaderMap) -> ApiRes accounts::authorize(&app, &headers, false, true).await?; let snapshot = app.snapshot.read().await; 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) { @@ -153,6 +173,7 @@ fn router(app: Arc) -> Router { ) .route("/api/posts", get(posts)) .route("/api/posts/{slug}", get(public_post)) + .route("/api/posts/{slug}/pages/{name}", get(page)) .route("/api/assets/{slug}/{name}", get(asset)) .route("/api/stats", get(stats)) .route("/api/auth/register", post(accounts::register)) @@ -186,19 +207,32 @@ fn router(app: Arc) -> Router { )) .with_state(app) } -async fn refresh(app: &App, repo: &std::path::Path, source: &std::path::Path) -> Result<()> { - // Recover a previously committed ref even if snapshot publication was interrupted. - let mut current = app.snapshot.read().await.clone(); - let head = - String::from_utf8(content::git(repo, &["rev-parse", "refs/heads/main"], None).await?)?; - if current.commit != head.trim() { - current = content::scan(repo).await?; - } - let snapshot = ssh::import(repo, source, ¤t).await?; - if snapshot.commit != app.snapshot.read().await.commit { +async fn refresh(app: &App, source: &std::path::Path) -> Result<()> { + let current = app.snapshot.read().await.clone(); + let identities: Vec<_> = app + .accounts + .lock() + .await + .grants + .publishers + .keys() + .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?; - info!(action = "published", commit = %snapshot.commit); + info!(action = "published", revision = %snapshot.commit); + let changed = snapshot.commit != current.commit; *app.snapshot.write().await = snapshot; + if changed { + *app.indexed.write().await = None; + } } Ok(()) } @@ -238,7 +272,21 @@ async fn main() -> Result<()> { .open(data.join("writer.lock"))?; fs2::FileExt::try_lock_exclusive(&lock_file).context("内容目录已被另一个 API 进程使用")?; 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?; if command.as_deref() == Some("rebuild-index") { index::rebuild(&snapshot).await?; @@ -263,9 +311,9 @@ async fn main() -> Result<()> { "PUBLIC_ORIGIN 必须为不带末尾斜杠的站点 origin" ); 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( std::env::var("CHUBLOG_SSH_CONFIG") .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?; ensure!( accounts.grants.repository == source, - "授权配置仓库与 INGRESS_REPO 不一致" + "授权配置仍指向旧共享仓库或与 INGRESS_DIR 不一致,请先按 README 迁移授权配置" ); let app = Arc::new(App { data, @@ -290,11 +338,14 @@ async fn main() -> Result<()> { let worker_app = app.clone(); let worker = tokio::spawn(async move { let mut ticks = 0; + let mut retry_at = tokio::time::Instant::now(); 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); } - 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(); if worker_app.indexed.read().await.as_deref() != Some(snapshot.commit.as_str()) { sync_index(&worker_app, &snapshot).await; @@ -306,6 +357,9 @@ async fn main() -> Result<()> { *worker_app.indexed.write().await = None; 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; tokio::time::sleep(Duration::from_secs(2)).await; diff --git a/apps/api/src/ssh.rs b/apps/api/src/ssh.rs index 0b1dc49..8a6ccbf 100644 --- a/apps/api/src/ssh.rs +++ b/apps/api/src/ssh.rs @@ -1,5 +1,5 @@ use crate::accounts::{Grant, Role}; -use crate::content::{self, Snapshot}; +use crate::content::{self, Scan, Snapshot}; use anyhow::{Context, Result, bail, ensure}; use serde::{Deserialize, Serialize}; use std::{ @@ -44,16 +44,44 @@ fn authorized<'a>(cfg: &'a Config, identity: &str) -> Result<&'a Grant> { .get(identity) .filter(|p| p.enabled && p.role != Role::Reader && p.ssh_key.is_some()) .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) } +pub fn author_repository(root: &Path, identity: &str) -> Result { + 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 { + 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 { let mut total = 0u64; for entry in std::fs::read_dir(path)? { @@ -88,20 +116,22 @@ pub async fn gate(identity: &str) -> Result<()> { "仅允许对 content.git 执行 Git 推送或读取" ); 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() .create(true) .truncate(false) .write(true) - .open(cfg.repository.join("ssh.lock"))?; + .open(cfg.repository.join(format!(".{identity}.lock")))?; fs2::FileExt::try_lock_exclusive(&lock).context("另一位发布者正在访问仓库,请稍后重试")?; + init_author(&cfg.repository, identity).await?; + directory_size(&cfg.repository)?; let executable = if receiving { "git-receive-pack" } else { "git-upload-pack" }; let mut child = Command::new(executable) - .arg(&cfg.repository) + .arg(&repo) .env_clear() .env("PATH", "/usr/bin:/bin") .env("HOME", "/nonexistent") @@ -138,12 +168,7 @@ pub async fn gate(identity: &str) -> Result<()> { Ok(()) } -pub async fn validate_change( - repo: &Path, - previous: &str, - next: &str, - publisher: &Grant, -) -> Result { +pub async fn validate_change(repo: &Path, previous: &str, next: &str) -> Result { ensure!( next.len() == 40 && 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?; - // 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) } @@ -214,7 +209,15 @@ pub async fn pre_receive() -> Result<()> { 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 current = authorized(&latest, &identity)?; ensure!( @@ -228,34 +231,131 @@ pub async fn pre_receive() -> Result<()> { Ok(()) } -// Fetch into API-owned storage: the SSH account cannot alter published refs or snapshots. -pub async fn import(repo: &Path, source: &Path, current: &Snapshot) -> Result { - content::git( - repo, - &[ - "fetch", - "--no-tags", - "--no-recurse-submodules", - "--", - source.to_str().context("接收路径无效")?, - "refs/heads/main", - ], - None, - ) - .await?; - let commit = String::from_utf8(content::git(repo, &["rev-parse", "FETCH_HEAD"], None).await?)?; - let commit = commit.trim(); - if commit == current.commit { - return Ok(current.clone()); +// Each fetch receives only one author's objects into an API-owned private cache. +pub async fn import_authors( + data: &Path, + source: &Path, + current: &Snapshot, + identities: &[String], +) -> Result { + 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, + &[ + "-c", + &safe, + "ls-remote", + incoming.to_str().context("接收路径无效")?, + "refs/heads/main", + ], + None, + ) + .await?; + if refs.is_empty() { + return Ok::<_, anyhow::Error>(None); + } + content::git( + &repo, + &[ + "-c", + &safe, + "fetch", + "--no-tags", + "--no-recurse-submodules", + "--", + incoming.to_str().context("接收路径无效")?, + "refs/heads/main", + ], + None, + ) + .await?; + 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::() + }) + .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 snapshot = content::scan_at(repo, commit).await?; - content::git( - repo, - &["update-ref", "refs/heads/main", commit, ¤t.commit], - None, + let repo = content::init(data).await?; + let digest = content::git( + &repo, + &["hash-object", "--stdin"], + Some(&serde_json::to_vec(&combined.revisions)?), ) .await?; - Ok(snapshot) + combined.commit = String::from_utf8(digest)?.trim().into(); + Ok(combined) } pub fn authorized_keys() -> Result<()> { diff --git a/apps/api/src/tests.rs b/apps/api/src/tests.rs index d3801b1..4b22b5d 100644 --- a/apps/api/src/tests.rs +++ b/apps/api/src/tests.rs @@ -9,31 +9,30 @@ 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" ) } -fn grant(prefix: &str) -> Grant { - Grant { - enabled: true, - role: Role::Publisher, - prefixes: vec![prefix.into()], - ssh_key: None, - } -} async fn commit( repo: &std::path::Path, parent: Option<&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 { content::git(repo, &["read-tree", "--empty"], None) .await .unwrap(); for (path, mode, body) in files { let object = String::from_utf8( - content::git( - repo, - &["hash-object", "-w", "--stdin"], - Some(body.as_bytes()), - ) - .await - .unwrap(), + content::git(repo, &["hash-object", "-w", "--stdin"], Some(body)) + .await + .unwrap(), ) .unwrap(); content::git( @@ -117,117 +116,143 @@ fn rejects_active_content_and_unsafe_metadata_but_keeps_inert_code() { .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] -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 repo = content::init(dir.path()).await.unwrap(); let zero = "0000000000000000000000000000000000000000"; - let first = commit( + let body = markdown("first", "published", "Hello"); + let files = [ + ("index.txt", "100644", "first posts/writing index.md\n"), + ("posts/writing/index.md", "100644", body.as_str()), + ]; + let first = commit(&repo, None, &files).await; + let snapshot = ssh::validate_change(&repo, zero, &first).await.unwrap(); + assert_eq!(snapshot.posts["first"].path, "posts/writing/index.md"); + let removed = commit( &repo, - None, - &[( - "posts/alice-first/index.md", + Some(&first), + &[("index.txt", "100644", ""), files[1]], + ) + .await; + assert!( + 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", - &markdown("alice-first", "published", "Hello"), - )], - ) - .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, - Some(&first), - &[( - "posts/alice-first/index.md", + "-----BEGIN PRIVATE KEY-----", + ), + ( + "posts/writing/hidden.md", "100644", - &markdown("alice-first", "published", "Hello").replace('\n', "\r\n"), - )], - ) - .await; - assert!( - ssh::validate_change(&repo, &first, &crlf, &grant("bob-")) - .await - .is_err(), - "normalization must not bypass path authorization" - ); - let second = commit( + "", + ), + ("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 + .is_err(), + "accepted {path}" + ); + } + let missing = commit( &repo, Some(&first), - &[( - "posts/alice-first/index.md", - "100644", - &markdown("alice-first", "draft", "Private"), - )], + &[("index.txt", "100644", "first posts/missing index.md")], ) .await; - ssh::validate_change(&repo, &first, &second, &grant("alice-")) - .await - .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( + assert!(content::scan_at(&repo, &missing).await.is_err()); + let duplicate_body = commit( &repo, Some(&first), - &[("posts/alice-first/index.md", "120000", "/etc/passwd")], + &[ + files[0], + files[1], + ("posts/writing/index.txt", "100644", body.as_str()), + ], ) .await; - assert!( - ssh::validate_change(&repo, &first, &link, &grant("alice-")) + assert_eq!( + content::scan_at(&repo, &duplicate_body) .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, Some(&first), - &[( - "posts/alice-first/index.md", - "100755", - &markdown("alice-first", "published", "Hello"), - )], + &[ + ("index.txt", "100644", "first posts/writing index.txt"), + ("posts/writing/index.txt", "100644", body.as_str()), + ], ) .await; - assert!( - ssh::validate_change(&repo, &first, &executable, &grant("alice-")) - .await - .is_err() + assert_eq!( + content::scan_at(&repo, &plain).await.unwrap().posts["first"].format, + "text" ); - let hidden = commit( + let draft_body = markdown("first", "draft", "Secret"); + let draft = commit( &repo, Some(&first), - &[(".github/workflows/run.yml", "100644", "bad")], + &[ + ("index.txt", "100644", "first posts/writing index.markdown"), + ("posts/writing/index.markdown", "100644", &draft_body), + ], ) .await; - assert!( - 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()) + content::persist(dir.path(), &content::scan_at(&repo, &draft).await.unwrap()) .await .unwrap(); let public: Snapshot = serde_json::from_slice( @@ -236,7 +261,123 @@ async fn git_checks_namespace_history_and_file_modes_before_publication() { .unwrap(), ) .unwrap(); - assert!(public.posts.is_empty(), "draft leaked into public snapshot"); + assert!(public.posts.is_empty()); +} + +#[tokio::test] +async fn custom_body_selection_ignores_unindexed_text_and_unreferenced_images() { + let dir = tempfile::tempdir().unwrap(); + let repo = content::init(dir.path()).await.unwrap(); + let body = markdown("first", "published", "![cover](cover.png)").replace("slug: first\n", ""); + let mut image = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(1, 1) + .write_to(&mut image, image::ImageFormat::Png) + .unwrap(); + let commit = commit_bytes( + &repo, + None, + &[ + ( + "index.txt", + "100644", + b"first posts/writing article.markdown\n", + ), + ("posts/writing/article.markdown", "100644", body.as_bytes()), + ("posts/writing/notes.txt", "100644", b"Unselected notes"), + ("posts/unlisted/draft.txt", "100644", b"Not registered"), + ("posts/writing/cover.png", "100644", image.get_ref()), + ("posts/writing/private.png", "100644", image.get_ref()), + ], + ) + .await; + let snapshot = content::scan_at(&repo, &commit).await.unwrap(); + assert_eq!(snapshot.posts.len(), 1); + let post = &snapshot.posts["first"]; + assert_eq!(post.path, "posts/writing/article.markdown"); + assert_eq!(post.meta.slug, "first"); + assert_eq!(post.assets.len(), 1); + assert_eq!(post.assets[0].name, "cover.png"); + assert!( + !serde_json::to_string(&snapshot) + .unwrap() + .contains("Unselected notes") + ); +} + +#[tokio::test] +async fn author_import_isolates_objects_and_handles_same_ids_index_deletion_and_recovery() { + let dir = tempfile::tempdir().unwrap(); + let ingress = dir.path().join("ingress"); + std::fs::create_dir(&ingress).unwrap(); + let alice = ssh::init_author(&ingress, "alice").await.unwrap(); + let bob = ssh::init_author(&ingress, "bob").await.unwrap(); + let a_body = markdown("first", "draft", "Alice private body"); + let b_body = markdown("first", "published", "Bob public body"); + let a = commit( + &alice, + None, + &[ + ("index.txt", "100644", "first posts/a index.md"), + ("posts/a/index.md", "100644", &a_body), + ], + ) + .await; + let b = commit( + &bob, + None, + &[ + ("index.txt", "100644", "first posts/b index.md"), + ("posts/b/index.md", "100644", &b_body), + ], + ) + .await; + content::git(&alice, &["update-ref", "refs/heads/main", &a], None) + .await + .unwrap(); + content::git(&bob, &["update-ref", "refs/heads/main", &b], None) + .await + .unwrap(); + assert!( + content::git(&alice, &["cat-file", "-t", &b], None) + .await + .is_err(), + "Bob objects leaked into Alice repository" + ); + let data = dir.path().join("api"); + let names = vec!["alice".into(), "bob".into()]; + let first = ssh::import_authors(&data, &ingress, &Snapshot::default(), &names) + .await + .unwrap(); + assert_eq!(first.posts.len(), 2); + assert_eq!(first.posts["alice~first"].author, "alice"); + assert_eq!(first.posts["bob~first"].article_id, "first"); + let same = ssh::import_authors(&data, &ingress, &first, &names) + .await + .unwrap(); + assert_eq!(same.commit, first.commit); + let removed = commit( + &bob, + Some(&b), + &[ + ("index.txt", "100644", ""), + ("posts/b/index.md", "100644", &b_body), + ], + ) + .await; + content::git(&bob, &["update-ref", "refs/heads/main", &removed], None) + .await + .unwrap(); + let next = ssh::import_authors(&data, &ingress, &first, &names) + .await + .unwrap(); + assert!(!next.posts.contains_key("bob~first")); + assert!(next.posts.contains_key("alice~first")); + // Recover all authors from private Git caches after losing the derived collection. + let rebuilt = ssh::import_authors(&data, &ingress, &Snapshot::default(), &names) + .await + .unwrap(); + assert_eq!(rebuilt.commit, next.commit); + assert_eq!(rebuilt.posts.len(), 1); } #[test] fn ssh_keys_cannot_inject_options_or_commands() { @@ -347,6 +488,7 @@ async fn app(dir: &std::path::Path) -> Arc { hash_slots: Arc::new(Semaphore::new(2)), snapshot: RwLock::new(Snapshot { commit: "test".into(), + revisions: BTreeMap::new(), posts: BTreeMap::from([ ("alice-public".into(), published), ("alice-secret".into(), draft), @@ -459,7 +601,7 @@ async fn registration_login_grants_revocation_and_private_content_boundaries() { .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( app.clone(), "PATCH", diff --git a/content/index.txt b/content/index.txt new file mode 100644 index 0000000..1bcf960 --- /dev/null +++ b/content/index.txt @@ -0,0 +1 @@ +welcome posts/welcome index.md diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 98c8fae..abe4996 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -8,7 +8,7 @@ RUN groupadd -g 10002 ingress && useradd -u 10001 -g ingress -M -s /usr/sbin/nol FROM base AS api 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"] FROM base AS ssh diff --git a/deploy/gitconfig b/deploy/gitconfig index 4c2e9d8..0b32e66 100644 --- a/deploy/gitconfig +++ b/deploy/gitconfig @@ -1,2 +1,2 @@ [safe] - directory = /srv/ingress/content.git + directory = /srv/ingress/authors/* diff --git a/deploy/init-volumes.sh b/deploy/init-volumes.sh index d210c95..8d6a4af 100644 --- a/deploy/init-volumes.sh +++ b/deploy/init-volumes.sh @@ -7,8 +7,7 @@ chmod 755 /auth chown 10002:10002 /srv/ingress chmod 750 /srv/ingress chown 10003:10003 /db-data /db-logs -if [ ! -d /srv/ingress/content.git ]; then - setpriv --reuid=10002 --regid=10002 --clear-groups git init --bare --initial-branch=main --shared=group /srv/ingress/content.git - setpriv --reuid=10002 --regid=10002 --clear-groups git -C /srv/ingress/content.git config receive.denyNonFastForwards true - setpriv --reuid=10002 --regid=10002 --clear-groups git -C /srv/ingress/content.git config receive.denyDeletes true -fi +# Per-author repositories are created lazily by the authenticated SSH gateway. +mkdir -p /srv/ingress/authors +chown 10002:10002 /srv/ingress/authors +chmod 750 /srv/ingress/authors diff --git a/docs/API.md b/docs/API.md index 77dc0a5..43c085a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,12 +1,21 @@ # 后端 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/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/stats` | 公共,文章数、标签数、正文源字节数、启动以来文章请求数 | | 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/password` | 登录 + Origin + CSRF;`{old_password,new_password}`,成功后全部会话失效 | | 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}` | -角色:`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 接收与阅读仍可继续,索引在恢复后自动重建;站长审核暂未启用。 diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..8a2b0d4 --- /dev/null +++ b/docs/deployment.md @@ -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)。 diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..6c84a6a --- /dev/null +++ b/docs/development.md @@ -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 缓存和旧提交直链的回收由平台管理。 diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..e074c82 --- /dev/null +++ b/docs/operations.md @@ -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=*`。 diff --git a/docs/publishing.md b/docs/publishing.md new file mode 100644 index 0000000..9906f62 --- /dev/null +++ b/docs/publishing.md @@ -0,0 +1,107 @@ +# 账户授权与文章发布 + +[返回项目首页](../README.md) + +## 用户与发布授权 + +完整接口见 [API 文档](API.md)。 + +1. 注册:`POST /api/auth/register`,正文 `{ "username": "alice", "password": "至少12字节的口令" }`。默认角色 `reader`,绝不接受客户端指定角色。`ALLOW_REGISTRATION=false` 可关闭注册。 +2. 登录:`POST /api/auth/login`,相同字段。浏览器保存 HttpOnly Cookie;返回 `csrf`。所有修改请求必须携带站点 `Origin` 和 `X-CSRF-Token`。 +3. 本地生成 `ssh-keygen -t ed25519` 或 `ssh-keygen -t rsa -b 3072`;登录后 `PUT /api/account/ssh-key` 提交 `{ "public_key": "ssh-ed25519 AAAA..." }`,只交公钥。RSA 使用标准 `ssh-rsa AAAA...` 公钥文本,支持 2048–8192 位;实际认证使用 RSA/SHA-256 或 RSA/SHA-512,不启用旧的 SHA-1 签名。见 [OpenSSH 说明](https://www.openssh.com/txt/release-8.8)。 +4. 管理员登录后调用 `PATCH /api/admin/users/alice`,正文 `{ "enabled": true, "role": "publisher" }`。服务器按公钥绑定的用户分配独立仓库。 +5. 此时 SSH 才接受该用户的密钥;`reader`、停用用户、未绑密钥的用户均不能访问 Git。管理员也需登记公钥才能发布。旧 `prefixes` 字段仅为兼容保留,不再用于文章隔离。 + +- 授权与密钥独立:知道服务器地址、有网站账户、上传了公钥都不足以推送。 +- 设回 `reader` 或 `enabled:false` 会撤回发布授权,并使现有网站会话失效。 +- 禁止管理员停用自己或取消自己的管理角色。 + +## 本地 Git 工作流 + +- 先通过可信渠道核对服务器 Ed25519 主机密钥指纹;不要禁用主机密钥检查。 +- 主机密钥保存在独立持久卷,重建容器不会更换。 +- 所有发布者使用同一个 OS 账户 `git`,身份由对应公钥的强制命令绑定,不由 Git author/email 决定。 + +```sh +# ~/.ssh/config +Host chublog + HostName blog.example.com + Port 2222 + User git + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes + +# 无论作者是谁,别名都是 content.git;服务端按认证身份映射个人仓库。 +# 首次 clone 自动创建服务器裸仓库,作者无须自己创建裸仓库。 +git clone chublog:content.git my-articles +cd my-articles +# 仓库已有内容时,每轮编辑前先 git pull --ff-only。 +# 创建 index.txt 和正文,格式见下文。 +git add index.txt posts/first +git commit -m 'Publish first article' +git push -u origin main +``` + +## 文章目录与清单 + +仓库结构示例: + +```text +index.txt +posts/ +└── first/ + ├── article.md + └── cover.png +``` + +`index.txt` 为 UTF-8 三列文本,以空格或 Tab 分隔,无表头,允许空行: + +```text +first posts/first article.md +notes posts/my-notes notes.markdown +``` + +- 三列依次为**文章 ID、相对文件夹、正文文件名**。 +- ID 在作者内唯一,支持 1–40 位小写字母、数字及中间短横线,另须避开保留名称。 +- 目录必须是 `posts/<目录名>`,目录名遵循 slug 规则,最多 80 字节;文件名只允许 ASCII 字母、数字、短横线、下划线与点,不能以点开头或含 `..`。 +- 同一目录不能重复登记;正文可以任意命名,扩展名为 `.md`、`.markdown` 或 `.txt`。 +- 索引所指目录和正文必须存在,路径穿越和子目录正文被拒绝。 +- 索引上限 64 KiB、1000 项。 + +## 正文与图片 + +- 正文沿用 YAML Frontmatter(参考[正文样例](../content/posts/welcome/index.md)),包含 `title/date/updated/status/tags/description`;`slug` 可省略,如填写则必须等于索引 ID。 +- `.txt` 正文也需要 Frontmatter,API 通过 `format: text` 表明正文应作为纯文本展示;其他正文为 `format: markdown`。 +- 图片置于同一目录,通过 `![说明](cover.png)` 引用。 + +## 新增、修改与撤回 + +| 作者操作 | 系统处理 | +| --- | --- | +| 新建正文并在索引新增一行 | 校验后导入文章,在 ChuSQL 创建作者、ID、正文路径、状态等元信息 | +| 修改正文或索引中的目录/文件名,保留 ID | 更新同一文章;站内 ID 不随目录移动而变化 | +| 删除索引中的一行 | 移除当前阅读内容、图片入口及对应数据库元信息,无需先删除本地正文 | +| 把索引清空 | 移除该作者所有当前文章,不影响其他作者 | + +- 未被索引选中的正文及目录视为无效内容,不导入阅读快照或数据库;图片只有被选中正文引用才作为公开资源导入;仍计算仓库容量并执行安全校验。 +- 删除正文但保留其索引项属于错误,整次推送会被拒绝。 +- `status: draft` 可保留登记但不公开;物理删除文件和撤回文章均不会擦除 Git 历史。 + +## 提交与并发限制 + +- MVP 每次只接受**一个直接继承个人远端 main 的提交**,首次仅允许一个根提交。 +- 禁止 merge、force push、删除 main、标签及其他分支;多次本地提交应先整理为一次提交,再变基到最新远端。 +- 同一作者在多台电脑操作仍需处理并发冲突,不同作者互不争用同一个仓库锁。 + +## 作者隔离与文章地址 + +- 站内文章 ID 为 `作者名~文章ID`,例如 Alice 的 `first` 对应 `/api/posts/alice~first`;不同作者可以使用相同的本地 ID。 +- 服务端分别保存每位作者的 Git 对象与历史,SSH 不能指定别人的仓库路径;作者无法通过 clone 或按提交哈希 fetch 读取其他作者草稿。 +- 作者自己已克隆的历史仍可在本地保留,不能靠撤回收回副本。 + +## 发布状态 + +- API 自动同步,一般约 2 秒,数据库重建、失败重试或较多作者可能延迟。 +- `GET /api/admin/status` 的 `commit` 是作者提交映射的集合摘要,不是任一作者 Git 提交;`indexed_commit` 表示数据库已同步的集合版本。 +- Git 接受表示接收校验完成,不保证数据库已同步。 +- 当前不做站长内容审核,审核计划见 [TODO](../TODO.md)。 diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..8be0c95 --- /dev/null +++ b/docs/security.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/解码库的持续安全更新。 diff --git a/scripts/smoke.py b/scripts/smoke.py index 58e5096..d3b9706 100755 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -72,6 +72,25 @@ with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary: envfile.chmod(0o600) project = 'chublog-test-' + secrets.token_hex(4) 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: run(compose + ['up', '-d', '--build']) admin = Client() @@ -95,6 +114,8 @@ with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary: article.parent.mkdir(parents=True) original = (ROOT / 'content/posts/welcome/index.md').read_text().replace('slug: "welcome"', 'slug: "alice-first"') article.write_text(original) + manifest = local / 'index.txt' + manifest.write_text('alice-first posts/alice-first index.md\n') def commit(): run(['git', '-C', str(local), 'add', '.']) @@ -109,23 +130,26 @@ with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary: push(False) admin.call('PATCH', '/api/admin/users/alice', {'enabled': True, 'role': 'publisher', 'prefixes': ['alice-']}) 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) - 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'] result = subprocess.run(ssh + ['id'], capture_output=True) assert result.returncode != 0, 'SSH shell command unexpectedly succeeded' # 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': article.write_text(original + '\n\n') elif kind == 'symlink': article.unlink() article.symlink_to('/etc/passwd') - elif kind == 'foreign': - other = local / 'posts/bob-first/index.md' - other.parent.mkdir(parents=True) - other.write_text(original.replace('alice-first', 'bob-first')) + elif kind == 'manifest': + manifest.write_text('alice-first posts/missing index.md\n') + elif kind == 'certificate': + (article.parent / 'certificate.txt').write_text('-----BEGIN CERTIFICATE-----\ninvalid\n') + elif kind == 'gitignore': + (local / '.gitignore').write_text('*.pem\n') elif kind == 'oversize': article.write_text(original + 'x' * (257 * 1024)) 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) push(False, 'main:refs/heads/other') 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"')) commit() push() 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) commit() push() wait_for(lambda: Client().call('GET', '/api/stats')['published_posts'] == 1) 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']) - 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.call('PATCH', '/api/admin/users/alice', {'enabled': True, 'role': 'reader', 'prefixes': []}) article.write_text(original + '\nRevoked publisher\n')