Initialize ChuBlog backend with SSH Git publishing
This commit is contained in:
commit
a631d75cee
31 files changed
+4850
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
PUBLIC_ORIGIN=https://blog.example.com
|
||||||
|
SITE_ADDRESS=blog.example.com
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
# Generate with chublog-api hash-password; keep single quotes around the hash.
|
||||||
|
ADMIN_PASSWORD_HASH='$argon2id$REPLACE_WITH_GENERATED_HASH'
|
||||||
|
ALLOW_REGISTRATION=true
|
||||||
|
CHUSQL_ROOT_PASSWORD=replace-with-random-root-password-at-least-16-characters
|
||||||
|
CHUSQL_PASSWORD=replace-with-different-random-index-password-at-least-16-characters
|
||||||
|
SSH_PORT=2222
|
||||||
|
HTTP_PORT=80
|
||||||
|
HTTPS_PORT=443
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
/target/
|
||||||
|
/data/
|
||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
*.log
|
||||||
|
/deploy/bin/
|
||||||
|
/deploy/secrets/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
Generated
+1442
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["apps/api", "crates/chusql-driver"]
|
||||||
|
resolver = "3"
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# ChuBlog — 项目规划
|
||||||
|
|
||||||
|
> 状态:规划草案 v1.0 · 2026-10-15
|
||||||
|
> 定位:自托管个人技术博客;Markdown 文件是内容源,Git 管理版本,ChuSQL 提供可重建的内容索引。
|
||||||
|
|
||||||
|
## 1. 目标与原则
|
||||||
|
|
||||||
|
- 面向个人作者,支持公开阅读、文章归档、标签、代码高亮、目录、SEO、RSS 和深色模式。
|
||||||
|
- 管理员可在经过认证的后台上传 Markdown、预览、修改元数据、发布、撤回与删除文章;也支持本地 Git 工作流。
|
||||||
|
- **Git 仓库内的 Markdown 与资源文件是唯一权威内容源**;ChuSQL 不保存唯一一份正文,只维护可重建索引。
|
||||||
|
- 未授权用户不得发布页面、提交到内容仓库或触发任意服务器 Git 命令。
|
||||||
|
- 保持 MVP 小而可靠;不引入公共用户注册、评论、插件市场、队列/微服务或复杂角色系统。
|
||||||
|
- ChuSQL 暂不视为已通过生产验证的数据库;博客即使索引丢失,也能从 Git 恢复文章。
|
||||||
|
|
||||||
|
## 2. 技术栈
|
||||||
|
|
||||||
|
| 层 | 选型 | 职责 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 站点 | Astro + TypeScript | 内容页面、按需服务端渲染、SEO、RSS |
|
||||||
|
| 样式 | Tailwind CSS | 响应式设计、暗色模式 |
|
||||||
|
| Markdown | Astro Markdown 管线(remark/rehype、Shiki) | Markdown/Frontmatter 解析、代码高亮 |
|
||||||
|
| 后端 | Rust + Axum + Tokio + Serde | 管理员接口、认证、上传校验、Git 操作、索引同步 |
|
||||||
|
| 数据库 | ChuSQL | 已发布文章元数据、标签、搜索索引(按当前能力逐步实现) |
|
||||||
|
| DB 客户端 | `chusql-rs`(独立 crate) | ChuSQL 网络协议连接、查询、错误处理 |
|
||||||
|
| 反向代理 | Caddy | HTTPS、请求体限制、路由 |
|
||||||
|
| 运行 | Debian + Docker Compose | 管理服务与持久卷 |
|
||||||
|
| 版本控制 | Git | 文章与图片的版本记录、审计与恢复 |
|
||||||
|
|
||||||
|
不自行编写 Markdown 语法解释器。MVP 禁止文章中的原始 HTML 或采用严格清理策略;默认不启用任意 MDX/JS 组件,因为上传文档不能成为服务器执行代码的入口。
|
||||||
|
|
||||||
|
## 3. 系统架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
读者浏览器 ── HTTPS ── Caddy ── Astro(公共页面)
|
||||||
|
│
|
||||||
|
├─ 只读文章文件 / content(挂载)
|
||||||
|
└─ Axum 只读 API(必要时)── ChuSQL(索引)
|
||||||
|
|
||||||
|
管理员浏览器 ── HTTPS ── Caddy ── Axum(后台 API)
|
||||||
|
│
|
||||||
|
├─ 管理员认证 / CSRF / 限流
|
||||||
|
├─ Markdown 校验及暂存
|
||||||
|
├─ 受控 Git 内容仓库
|
||||||
|
└─ 索引同步 ── chusql-rs ── ChuSQL
|
||||||
|
|
||||||
|
管理员本地编辑器 ── Git 受保护分支 / 仓库同步流程 ── 内容仓库
|
||||||
|
```
|
||||||
|
|
||||||
|
- 只暴露 80/443 TCP;Axum、Astro 与 ChuSQL 使用私有 Docker 网络。
|
||||||
|
- 现有 P2P 服务可独立继续使用 UDP 7001,互不影响。
|
||||||
|
- **不允许用户请求决定任意磁盘路径、Git 仓库地址、Git 参数或 shell 命令。**
|
||||||
|
- Astro 若需上传后立即生效,使用按需 SSR 读取受控的内容快照;不要依赖仅构建时扫描的静态内容集合。若改用纯静态生成,则明确采用“提交后重新构建并原子部署”的发布策略。MVP 选择 **SSR + 已发布内容快照**。
|
||||||
|
|
||||||
|
## 4. 内容模型与存储布局
|
||||||
|
|
||||||
|
```text
|
||||||
|
chublog/
|
||||||
|
├── apps/
|
||||||
|
│ ├── web/ # Astro 站点
|
||||||
|
│ └── api/ # Axum 后台与只读 API
|
||||||
|
├── crates/
|
||||||
|
│ └── chusql-rs/ # 若尚无独立仓库,可先工作区开发再拆分
|
||||||
|
├── content/ # 独立 Git 仓库或子目录(受控工作树)
|
||||||
|
│ ├── posts/
|
||||||
|
│ │ └── haskell-monad/
|
||||||
|
│ │ ├── index.md
|
||||||
|
│ │ └── cover.webp
|
||||||
|
│ └── .gitignore
|
||||||
|
├── deploy/
|
||||||
|
│ ├── compose.yaml
|
||||||
|
│ └── Caddyfile
|
||||||
|
└── PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
文章 Frontmatter 规范示例:
|
||||||
|
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
title: "Haskell Monad 入门"
|
||||||
|
slug: "haskell-monad"
|
||||||
|
date: "2026-10-15"
|
||||||
|
updated: "2026-10-15"
|
||||||
|
status: "published"
|
||||||
|
tags: ["Haskell", "Functional Programming"]
|
||||||
|
description: "Monad 的基础概念"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Haskell Monad 入门
|
||||||
|
|
||||||
|
正文……
|
||||||
|
```
|
||||||
|
|
||||||
|
- `slug` 是唯一的 URL 标识,仅允许小写英文字母、数字、短横线,校验长度和保留名称。
|
||||||
|
- 文件路径由服务端基于已校验 slug 固定计算;禁止 `../`、绝对路径、符号链接和路径穿越。
|
||||||
|
- `status` 取 `draft | published`;草稿不可被未授权访问或从公开索引枚举。
|
||||||
|
- 图片仅允许必要类型(如 PNG/JPEG/WebP),按真实文件内容与尺寸校验,限制单文件和文章总大小。SVG 暂不开放后台上传。
|
||||||
|
- 内容文件和 Frontmatter 是权威信息;ChuSQL 中的记录可由全量扫描重新建立。
|
||||||
|
|
||||||
|
## 5. Git 内容管理工作流
|
||||||
|
|
||||||
|
### 5.1 后台上传与发布
|
||||||
|
|
||||||
|
1. 管理员登录,上传 `.md` 与附属图片;后端检查会话、CSRF 与限流。
|
||||||
|
2. 在隔离的暂存目录校验文件、解析 Frontmatter、检查重复 slug、限制大小和 Markdown 语法范围。
|
||||||
|
3. 展示预览;管理员明确点击“发布”。预览默认不公开。
|
||||||
|
4. 服务端通过受控文件操作写入仓库指定目录,创建 Git commit(提交者为固定服务身份,日志可附管理员操作 ID)。
|
||||||
|
5. 提交成功后生成/切换已发布内容快照,并更新 ChuSQL 索引;公开页面仅读取已发布快照。
|
||||||
|
6. 若索引更新失败,保留 Git 提交和待同步状态,由可重复执行的扫描任务恢复,不得静默显示“全部成功”。
|
||||||
|
|
||||||
|
### 5.2 本地 Git 编写
|
||||||
|
|
||||||
|
- 管理员可以在本地仓库编辑 Markdown,向受保护的主分支推送,或通过 PR 合并。
|
||||||
|
- 推荐在 CI 检查 Frontmatter、路径、大小、图片、链接及危险内容;只有已认证维护者可合并。
|
||||||
|
- 服务器采用**只读拉取/受控部署**,不接受互联网匿名推送;可使用带最小权限的部署凭证。
|
||||||
|
- **双入口写入存在冲突风险**:MVP 应只开放一个写入主入口。建议首先采用“后台上传写入本地 Git 仓库”,后续引入远端同步时再明确由 CI 部署作为唯一发布入口;或者反过来完全让后台创建 PR。禁止两个入口不加协调地向同一工作树写入。
|
||||||
|
|
||||||
|
### 5.3 更新、撤回、删除
|
||||||
|
|
||||||
|
- 更新是新的 Git commit;保留文章稳定 slug,必要时配置显式重定向。
|
||||||
|
- 撤回把 `status` 改为 `draft`,新快照不可公开访问。
|
||||||
|
- 删除从当前版本移除,但 Git 历史可能仍含内容;对于秘密泄露需要额外执行历史清理与凭证轮换,不能认为删除 commit 就彻底清除。
|
||||||
|
- 上传的草稿与私有资源必须与公开静态目录隔离,避免直链泄露。
|
||||||
|
|
||||||
|
## 6. 身份认证与抗垃圾页面
|
||||||
|
|
||||||
|
**MVP 采用单管理员、无公开注册、无公开投稿。** 未登录者没有任何创建、编辑、上传或 Git 写权限。这比单纯依靠验证码或限流更能从根源上避免垃圾页面泛滥。
|
||||||
|
|
||||||
|
安全控制:
|
||||||
|
|
||||||
|
1. 管理员账户配置在受保护的服务端环境或安全存储中;口令以 Argon2id 等成熟密码哈希存储,绝不保留明文。
|
||||||
|
2. 登录成功后发放服务端会话;Cookie 使用 `HttpOnly`、`Secure`、`SameSite`,设置到期时间和退出失效机制。生产建议支持 TOTP 2FA 或使用成熟身份代理实现 MFA。
|
||||||
|
3. 所有状态变更接口强制验证身份与 CSRF(包括上传、提交、发布和删除);校验 `Origin`,设置合理 CORS 策略。
|
||||||
|
4. 登录端点按 IP 与账户维度限流、失败退避和审计;上传限制文件大小、数量、总磁盘配额、请求频率、并发和执行耗时。
|
||||||
|
5. 反向代理和 API 分层限制请求体大小;服务仅接受预期 MIME 与文件结构;禁止 HTML/脚本执行、可疑链接协议与不受控资源嵌入。
|
||||||
|
6. Git 命令通过固定参数的进程 API 执行,**不用拼接 shell 字符串**;Git 工作树不允许访问任意仓库 hook,服务运行身份权限最小化。
|
||||||
|
7. 公开内容启用合理 CSP、HTML 清理、链接安全属性;后台不暴露到不必要的外部域名。
|
||||||
|
8. 采用日志审计:谁在什么时候上传、预览、发布、撤回、删除,以及对应 commit hash;不要记录口令或会话令牌。
|
||||||
|
|
||||||
|
若以后支持访客投稿:所有投稿必须进入**待审核区**,经管理员人工批准后才能进入正式 Git 分支;不得让访客直接触发公开发布。
|
||||||
|
|
||||||
|
## 7. API 草案
|
||||||
|
|
||||||
|
| 方法 | 路径 | 权限 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `POST` | `/api/admin/login` | 公共、限流 | 管理员登录 |
|
||||||
|
| `POST` | `/api/admin/logout` | 管理员 + CSRF | 注销会话 |
|
||||||
|
| `GET` | `/api/posts` | 公共 | 查询已发布文章元数据 |
|
||||||
|
| `GET` | `/api/posts/:slug` | 公共 | 已发布文章信息(或由 Astro 直接读取文件) |
|
||||||
|
| `POST` | `/api/admin/uploads` | 管理员 + CSRF | 上传临时文件、验证 |
|
||||||
|
| `POST` | `/api/admin/posts/preview` | 管理员 + CSRF | 安全预览 |
|
||||||
|
| `POST` | `/api/admin/posts/publish` | 管理员 + CSRF | 固定目录落盘并 Git 提交 |
|
||||||
|
| `PATCH` | `/api/admin/posts/:slug` | 管理员 + CSRF | 更新元数据/内容 |
|
||||||
|
| `POST` | `/api/admin/posts/:slug/unpublish` | 管理员 + CSRF | 撤回文章 |
|
||||||
|
| `DELETE` | `/api/admin/posts/:slug` | 管理员 + CSRF | 删除当前版本 |
|
||||||
|
| `POST` | `/api/admin/index/rebuild` | 管理员 + CSRF | 从 Git 内容重建 ChuSQL 索引 |
|
||||||
|
|
||||||
|
API 路由仅为计划,后续以实现的最小接口为准;对发布、更新、删除操作加互斥与幂等处理,防止并发提交损坏仓库状态。
|
||||||
|
|
||||||
|
## 8. ChuSQL 集成与一致性
|
||||||
|
|
||||||
|
MVP 仅需要 `posts` 元数据与 `tags`,字段例如 `slug, title, description, status, published_at, updated_at, path, git_commit`。字段类型、DDL、过滤和分页查询应按现有 ChuSQL 协议确定,不能预设兼容 PostgreSQL 或 SQLx。
|
||||||
|
|
||||||
|
- `chusql-rs` 第一版支持:连接、超时、查询/执行、结果与类型映射、错误处理、断线识别、集成测试。
|
||||||
|
- 不依赖目前未实现或未经验证的 ChuSQL 多语句事务;应用端以 Git 快照为权威,数据库仅做派生索引。
|
||||||
|
- 每次发布记录 Git commit hash,索引带对应内容版本号。建立独立的 `rebuild-index` 命令与定期一致性检查。
|
||||||
|
- ChuSQL 不可用时,已生成的公开内容快照仍能被读取;列表、标签等可从文件快照构建简易只读索引。禁止在数据库错误时覆盖或删除文章文件。
|
||||||
|
- 上传发布时采用严格顺序和可恢复状态:校验 → 暂存 → Git commit → 更新已发布快照 → 尝试索引同步;出错后可依据 commit 重新同步。
|
||||||
|
- 文章页面不要因 ChuSQL 宕机而泄露内部错误或凭证。
|
||||||
|
|
||||||
|
## 9. 实施阶段与验收
|
||||||
|
|
||||||
|
### Phase 0 — 先验证接口
|
||||||
|
|
||||||
|
- [ ] 检查 ChuSQL 服务端网络协议、查询语法和持久化行为。
|
||||||
|
- [ ] 决定 Astro SSR 在容器内读取哪个受控内容目录。
|
||||||
|
- [ ] 确定 Git 仓库部署路径、最小权限和快照切换方式。
|
||||||
|
|
||||||
|
### Phase 1 — 纯文件博客 MVP
|
||||||
|
|
||||||
|
- [ ] Astro 页面:首页、文章详情、标签、归档、404、暗色模式。
|
||||||
|
- [ ] Markdown + Frontmatter 校验、Shiki 代码高亮、文章目录。
|
||||||
|
- [ ] Astro SSR 读取已发布内容快照;提供 RSS、sitemap 和 SEO 元信息。
|
||||||
|
- [ ] 测试异常文件、超大文件和 XSS 内容不会被执行。
|
||||||
|
|
||||||
|
### Phase 2 — 后台 + Git 发布
|
||||||
|
|
||||||
|
- [ ] 单管理员认证、会话与 CSRF。
|
||||||
|
- [ ] 上传、预览、发布、编辑、撤回、删除。
|
||||||
|
- [ ] 每次发布产生可追踪的 Git commit;冲突与重复提交有明确错误反馈。
|
||||||
|
- [ ] 通过限流、文件配额、路径校验和无公开注册测试。
|
||||||
|
|
||||||
|
### Phase 3 — ChuSQL 驱动与索引
|
||||||
|
|
||||||
|
- [ ] 开发 `chusql-rs` 最小客户端及协议测试。
|
||||||
|
- [ ] 建立文章元数据表、查询接口与索引同步。
|
||||||
|
- [ ] 完成从 Git 仓库重建索引的命令与故障注入测试。
|
||||||
|
- [ ] 验证 ChuSQL 故障期间公开阅读不中断、恢复后索引一致。
|
||||||
|
|
||||||
|
### Phase 4 — 部署与运维
|
||||||
|
|
||||||
|
- [ ] Docker Compose、Caddy TLS、仅开放必要公网端口。
|
||||||
|
- [ ] 持久化 Git 内容与资源;数据库卷、日志独立管理。
|
||||||
|
- [ ] 备份 Git 仓库(含远端镜像)与上传资源;定期进行真实恢复演练。
|
||||||
|
- [ ] 监测磁盘、接口错误、登录失败、索引同步延迟。
|
||||||
|
- [ ] 更新依赖、最小容器权限、限制容器挂载与网络访问。
|
||||||
|
|
||||||
|
**MVP 完成判定**:未经认证无法写入或发布;管理员可上传并发布一篇 Markdown,页面即刻可读,Git 中有对应提交;重启容器后内容存在;故意停止 ChuSQL 后仍可浏览已发布文章;重新建立数据库后可重建索引;恶意路径、超大文件和脚本注入测试均不能成功。
|
||||||
|
|
||||||
|
## 10. 生产就绪与风险边界
|
||||||
|
|
||||||
|
- **文章内容生产可用性**来自 Git、可恢复的文件快照、访问控制、备份及恢复演练,不取决于 ChuSQL 当前成熟度。
|
||||||
|
- **ChuSQL 仍按实验性组件管理**:在事务原子性、并发控制、进程崩溃恢复、备份/恢复、认证与长时间稳定性未经验证前,不作为任何不可重建数据的唯一存储。
|
||||||
|
- 文件与数据库之间不追求假想的跨系统原子事务,而采用幂等同步和可回放 Git 提交恢复一致性。
|
||||||
|
- 不把管理员凭证、Git 写入凭证或 ChuSQL 密码写入 Git 仓库、镜像层和日志。
|
||||||
|
|
||||||
|
## 11. 暂不纳入 MVP
|
||||||
|
|
||||||
|
- 访客注册、公开投稿、评论、点赞与复杂权限角色。
|
||||||
|
- 前端在线富文本编辑器、协同编辑和复杂草稿工作流。
|
||||||
|
- 全文搜索引擎、消息队列、分布式部署、对象存储、多租户。
|
||||||
|
- 自建 Markdown 解释器、通用插件机制和 ChuSQL ORM/SQLx 适配。
|
||||||
|
|
||||||
|
## 12. 近期第一步
|
||||||
|
|
||||||
|
优先确认 ChuSQL 现有协议,同时创建 Astro 静态界面与 Axum 认证/上传原型。先跑通**管理员上传 → 校验 → Git 提交 → 公开页面读取文件**,之后再接入 ChuSQL 元数据索引。这样即使数据库研发推迟,博客仍可独立推进。
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# ChuBlog
|
||||||
|
|
||||||
|
Rust/Axum 博客后端。文章作者在本地编辑 Markdown,通过受限 SSH Git 推送发布。注册、登录、权限管理使用 HTTP API;文章仅通过 Git 修改。ChuSQL 使用项目内复制的 `crates/chusql-driver`,只存可重建索引。
|
||||||
|
|
||||||
|
本轮按最新需求只实现后端,没有前端或在线文章管理。`PLAN.md` 保留原始规划,当前范围与后续安排见 [TODO.md](TODO.md)。
|
||||||
|
|
||||||
|
## 项目与运行
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace/
|
||||||
|
├── ChuBlog/ # 本项目
|
||||||
|
│ └── crates/chusql-driver/ # 项目内的驱动副本
|
||||||
|
└── ChuSQL/ # 现有数据库及已编译 Linux 产物
|
||||||
|
```
|
||||||
|
|
||||||
|
开发检查:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test --all-targets
|
||||||
|
cargo fmt --check
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
部署基于 Debian 13,同架构构建 API 和 ChuSQL 后,组装运行镜像:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# ChuSQL 尚未编译时,先按其项目说明构建 Rust 存储库、server 与 bootstrap。
|
||||||
|
python3 scripts/prepare-deploy.py
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
构建时若 Debian 官方下载源较慢,可在 `.env` 配置 `DEBIAN_MIRROR=mirrors.ustc.edu.cn`,仍使用 Debian 签名校验。
|
||||||
|
|
||||||
|
`prepare-deploy.py` 复制相邻 ChuSQL 的既有编译产物,不修改该项目;没有产物会明确失败。跨平台部署须先在目标 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`;登录后 `PUT /api/account/ssh-key` 提交 `{ "public_key": "ssh-ed25519 AAAA..." }`,只交公钥。
|
||||||
|
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 公钥;拒绝把 `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
|
||||||
|
python3 scripts/smoke.py
|
||||||
|
```
|
||||||
|
|
||||||
|
联调使用独立随机命名的 Compose 项目、本机 18080/18443/12222 端口,测试结束清理自己创建的容器与卷。覆盖真实 SSH 身份与授权、非法推送、发布撤回、ChuSQL 索引、断库阅读、重启与撤权。该测试会拉取基础镜像,需要 Docker 可用。
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# ChuBlog — 后续待办计划
|
||||||
|
|
||||||
|
更新:2026-10-15。本文依据最新需求划分后续工作;`PLAN.md` 保留原始总体规划。当前只做后端,Git 是唯一文章写入入口。
|
||||||
|
|
||||||
|
## 当前已实现
|
||||||
|
|
||||||
|
- [x] Rust/Axum API:公开文章列表、详情、图片、基础统计、健康接口。
|
||||||
|
- [x] 用户注册、登录、退出、修改密码;Argon2id、服务端会话、Cookie、Origin/CSRF 校验。
|
||||||
|
- [x] reader / publisher / admin 权限;注册不能自选权限,管理员单独审批发布权、停用用户,修改权限使已有会话失效。
|
||||||
|
- [x] 用户上传/轮换自己的 Ed25519 公钥,公钥存在与具有发布权分别检查。
|
||||||
|
- [x] OpenSSH 强制命令:仅固定仓库的 Git push/clone;禁用 shell、SFTP/SCP、TTY、代理/端口转发、隧道与用户环境。
|
||||||
|
- [x] 发布者 slug 前缀授权,不允许重叠授权或修改其他命名空间。
|
||||||
|
- [x] pre-receive 校验 Git quarantine 内的候选提交:单次一个线性提交、仅 main、禁止删除分支、强推、标签及其他分支。
|
||||||
|
- [x] Markdown Frontmatter、路径、文件模式、大小配额、图片类型/内容/尺寸与链接安全校验;拒绝原始 HTML、隐藏控制符、符号链接、可执行文件及额外文件。
|
||||||
|
- [x] 独立 SSH 接收仓库与 API 正式仓库;从 Git 恢复公开快照,草稿不出现在公开 API/资源接口。
|
||||||
|
- [x] 项目内 `crates/chusql-driver` 副本;ChuSQL 公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。
|
||||||
|
- [x] Docker Compose、Caddy 反向代理、独立持久卷、容器资源限制、最小进程身份与网络隔离。
|
||||||
|
- [x] 后端/驱动自动测试、真实容器 SSH 联调脚本、运行/API/架构说明。
|
||||||
|
|
||||||
|
## 第一优先级:部署环境验收
|
||||||
|
|
||||||
|
- [ ] 配置真实域名、DNS、服务器防火墙与 Caddy 公网证书,确认只有所需 80/443/文章 SSH 端口对外开放。
|
||||||
|
- [ ] 从真实作者电脑验证 SSH 主机指纹、注册、公钥登记、管理员授权、首次推送和多人并发冲突处理。
|
||||||
|
- [ ] 给 Docker 数据目录设置文件系统硬配额;用压缩炸弹、大量失败推送和连接洪泛验证空间、CPU、内存、超时及进程数边界。
|
||||||
|
- [ ] 对正式 Git、用户哈希、授权文件、SSH 主机密钥制定加密备份与保留策略,执行完整恢复演练。
|
||||||
|
- [ ] 验证断电/磁盘满时 Git 引用、快照、账户及权限文件的一致性;故障恢复后不保留过期发布权。
|
||||||
|
- [ ] 在实际 ChuSQL 版本执行长期并发、进程崩溃恢复及索引故障注入验证;保持其派生索引定位。
|
||||||
|
- [ ] 固定经过验证的镜像 digest,建立 Git/OpenSSH/Rust 依赖和基础系统安全更新流程。
|
||||||
|
|
||||||
|
验收:有完整恢复记录;恶意推送不能影响正式内容;撤权后不能再次推送;数据库宕机仍可阅读,恢复后索引 commit 与内容一致。
|
||||||
|
|
||||||
|
## 第二优先级:账户与权限完善
|
||||||
|
|
||||||
|
- [ ] 管理员 MFA(成熟身份代理或 TOTP),敏感授权操作二次验证。
|
||||||
|
- [ ] 安全的管理员离线恢复/重置流程;用户密码找回需要先决定可信邮件或邀请渠道,禁止不验证身份的重置接口。
|
||||||
|
- [ ] 每用户多把公钥、指纹展示、单钥撤销、最后使用时间;保留每钥身份绑定。
|
||||||
|
- [ ] 在全局限流之上增加可信代理来源的 IP 与账户维度限制、失败退避,减轻公共认证桶被恶意占满的问题。
|
||||||
|
- [ ] 注册开放策略、邀请注册或邮件验证;保留注册与发布审批的权限分离。
|
||||||
|
- [ ] 持久化结构化审计,明确记录每次接受的 commit 对应站内用户与公钥指纹,并设置保留周期。当前 SSH 接收日志已有身份与 commit,API 导入日志只有 commit,尚无统一审计查询。
|
||||||
|
- [ ] 发布者之间的草稿隔离需求确认:当前授权作者能 clone 全仓库及历史。如需作者之间保密,改用独立收稿仓库,再统一发布。
|
||||||
|
|
||||||
|
## 第三优先级:Astro + TypeScript 阅读前端
|
||||||
|
|
||||||
|
- [ ] SSR 读取后端已发布 API:首页、文章详情、标签、归档、404、暗色模式与移动端排版。
|
||||||
|
- [ ] 使用 Astro Markdown/remark/rehype 和 Shiki:持续禁用原始 HTML/MDX,并清理输出,元数据必须作为文本转义。
|
||||||
|
- [ ] 文章目录、SEO、canonical、Open Graph、RSS、sitemap、robots。
|
||||||
|
- [ ] 注册/登录/账户页面、SSH 公钥设置和管理员发布授权页面;不增加在线文章写入口。
|
||||||
|
- [ ] 无障碍、错误状态、限流提示、安全 CSP 与浏览器端 XSS 回归验证。
|
||||||
|
|
||||||
|
验收:作者只需在本地编辑并 push,阅读端自动看到更新;草稿及资源无法由未登录访问者枚举或直链读取。
|
||||||
|
|
||||||
|
## 第四优先级:发布和运维体验
|
||||||
|
|
||||||
|
- [ ] 发布状态查询:区分 Git 已接受、内容已导入、索引已同步,向作者提供 commit 级诊断。
|
||||||
|
- [ ] 支持有上限的多次线性提交推送,逐个验证新增历史;保留对 merge/force push 的明确策略。
|
||||||
|
- [ ] 目录配额预警、安全 GC 与维护窗口;禁止在 SSH 请求里运行任意维护命令。
|
||||||
|
- [ ] 扩充现有 commit/行数对账为完整字段与标签对账;增加磁盘/登录失败/索引延迟/导入失败告警,以及受控恢复工具。
|
||||||
|
- [ ] API 分页与条件缓存:保留撤回后不可继续公开访问的语义。
|
||||||
|
- [ ] 更完整的统计:先定义访问口径与隐私策略,再决定是否持久化每日聚合;当前请求计数不是独立访客数,重启清零。
|
||||||
|
- [ ] 外部资源链接检查、slug 重定向与图片元数据清理;不能因此在服务器执行作者提供的命令。
|
||||||
|
|
||||||
|
## 后续再评估
|
||||||
|
|
||||||
|
在线编辑/上传/预览、全文检索、评论、多副本和更复杂的角色。若引入在线编辑,应先明确其如何经过同一授权、校验与发布协调流程,不能在现有 Git 仓库上新增未经协调的第二个写入口。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "chublog-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
argon2 = "0.5"
|
||||||
|
axum = "0.8"
|
||||||
|
base64 = "0.22"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
chusql-driver = { path = "../../crates/chusql-driver" }
|
||||||
|
fs2 = "0.4"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||||
|
rand = "0.8"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
pulldown-cmark = "0.13"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
use crate::{ApiError, ApiResult, App, bad, internal, origin, ssh};
|
||||||
|
use anyhow::{Context, Result, ensure};
|
||||||
|
use argon2::{
|
||||||
|
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||||
|
password_hash::{SaltString, rand_core::OsRng},
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use base64::{
|
||||||
|
Engine,
|
||||||
|
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||||
|
};
|
||||||
|
use rand::RngCore;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::{
|
||||||
|
collections::{BTreeMap, VecDeque},
|
||||||
|
path::Path as FilePath,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Role {
|
||||||
|
Reader,
|
||||||
|
Publisher,
|
||||||
|
Admin,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Grant {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub role: Role,
|
||||||
|
pub prefixes: Vec<String>,
|
||||||
|
pub ssh_key: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Credential {
|
||||||
|
hash: String,
|
||||||
|
}
|
||||||
|
struct Session {
|
||||||
|
username: String,
|
||||||
|
csrf: String,
|
||||||
|
expires: Instant,
|
||||||
|
}
|
||||||
|
pub struct Accounts {
|
||||||
|
credentials: BTreeMap<String, Credential>,
|
||||||
|
pub grants: ssh::Config,
|
||||||
|
sessions: BTreeMap<String, Session>,
|
||||||
|
attempts: VecDeque<Instant>,
|
||||||
|
changes: VecDeque<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn atomic(path: &FilePath, bytes: &[u8], mode: u32) -> Result<()> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let tmp = path.with_extension("tmp");
|
||||||
|
let mut file = tokio::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.write(true)
|
||||||
|
.mode(mode)
|
||||||
|
.open(&tmp)
|
||||||
|
.await?;
|
||||||
|
file.write_all(bytes).await?;
|
||||||
|
file.sync_all().await?;
|
||||||
|
tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode)).await?;
|
||||||
|
tokio::fs::rename(&tmp, path).await?;
|
||||||
|
let directory = std::fs::File::open(path.parent().context("文件缺少父目录")?)?;
|
||||||
|
directory.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn username(value: &str) -> Result<()> {
|
||||||
|
ensure!(
|
||||||
|
(3..=32).contains(&value.len())
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||||
|
&& value.starts_with(|c: char| c.is_ascii_lowercase()),
|
||||||
|
"用户名必须为 3–32 位小写字母、数字或短横线,以字母开头"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn password(value: &str) -> Result<()> {
|
||||||
|
ensure!(
|
||||||
|
(12..=1024).contains(&value.len()),
|
||||||
|
"口令必须为 12–1024 字节"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub async fn hash_password(value: String) -> Result<String> {
|
||||||
|
password(&value)?;
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
Argon2::default()
|
||||||
|
.hash_password(value.as_bytes(), &SaltString::generate(&mut OsRng))
|
||||||
|
.map(|hash| hash.to_string())
|
||||||
|
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
pub async fn load(
|
||||||
|
data: &FilePath,
|
||||||
|
config: &FilePath,
|
||||||
|
repository: std::path::PathBuf,
|
||||||
|
) -> Result<Accounts> {
|
||||||
|
let credentials: BTreeMap<String, Credential> =
|
||||||
|
match tokio::fs::read(data.join("users.json")).await {
|
||||||
|
Ok(bytes) => serde_json::from_slice(&bytes)?,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
let grants = match tokio::fs::read(config).await {
|
||||||
|
Ok(bytes) => serde_json::from_slice(&bytes)?,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => ssh::Config {
|
||||||
|
repository,
|
||||||
|
publishers: BTreeMap::new(),
|
||||||
|
},
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
let mut accounts = Accounts {
|
||||||
|
credentials,
|
||||||
|
grants,
|
||||||
|
sessions: BTreeMap::new(),
|
||||||
|
attempts: VecDeque::new(),
|
||||||
|
changes: VecDeque::new(),
|
||||||
|
};
|
||||||
|
if accounts.credentials.is_empty() {
|
||||||
|
let name = std::env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".into());
|
||||||
|
username(&name)?;
|
||||||
|
let hash =
|
||||||
|
std::env::var("ADMIN_PASSWORD_HASH").context("首次启动必须设置 ADMIN_PASSWORD_HASH")?;
|
||||||
|
let parsed = PasswordHash::new(&hash).map_err(|_| anyhow::anyhow!("管理员哈希格式无效"))?;
|
||||||
|
ensure!(
|
||||||
|
parsed.algorithm.as_str() == "argon2id",
|
||||||
|
"管理员哈希必须为 Argon2id"
|
||||||
|
);
|
||||||
|
accounts
|
||||||
|
.credentials
|
||||||
|
.insert(name.clone(), Credential { hash });
|
||||||
|
accounts.grants.publishers.insert(
|
||||||
|
name,
|
||||||
|
Grant {
|
||||||
|
enabled: true,
|
||||||
|
role: Role::Admin,
|
||||||
|
prefixes: vec![],
|
||||||
|
ssh_key: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
atomic(config, &serde_json::to_vec_pretty(&accounts.grants)?, 0o644).await?;
|
||||||
|
atomic(
|
||||||
|
&data.join("users.json"),
|
||||||
|
&serde_json::to_vec(&accounts.credentials)?,
|
||||||
|
0o600,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
ensure!(
|
||||||
|
accounts
|
||||||
|
.grants
|
||||||
|
.publishers
|
||||||
|
.iter()
|
||||||
|
.any(|(name, g)| g.role == Role::Admin
|
||||||
|
&& g.enabled
|
||||||
|
&& accounts.credentials.contains_key(name)),
|
||||||
|
"至少需要一个启用的管理员"
|
||||||
|
);
|
||||||
|
Ok(accounts)
|
||||||
|
}
|
||||||
|
fn rate(accounts: &mut Accounts) -> ApiResult<()> {
|
||||||
|
let now = Instant::now();
|
||||||
|
while accounts
|
||||||
|
.attempts
|
||||||
|
.front()
|
||||||
|
.is_some_and(|t| now.duration_since(*t) >= Duration::from_secs(60))
|
||||||
|
{
|
||||||
|
accounts.attempts.pop_front();
|
||||||
|
}
|
||||||
|
// One global bucket also bounds attacks distributed across IPs and account names.
|
||||||
|
if accounts.attempts.len() >= 10 {
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"认证请求过于频繁,请稍后再试".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
accounts.attempts.push_back(now);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn token() -> String {
|
||||||
|
let mut bytes = [0; 32];
|
||||||
|
rand::thread_rng().fill_bytes(&mut bytes);
|
||||||
|
URL_SAFE_NO_PAD.encode(bytes)
|
||||||
|
}
|
||||||
|
pub async fn authorize(
|
||||||
|
app: &App,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
change: bool,
|
||||||
|
admin: bool,
|
||||||
|
) -> ApiResult<String> {
|
||||||
|
let cookie = headers
|
||||||
|
.get(header::COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.split(';')
|
||||||
|
.filter_map(|p| p.trim().split_once('='))
|
||||||
|
.find(|(name, _)| *name == "chublog_session")
|
||||||
|
.map(|(_, v)| v)
|
||||||
|
.unwrap_or("");
|
||||||
|
let mut accounts = app.accounts.lock().await;
|
||||||
|
let session = accounts
|
||||||
|
.sessions
|
||||||
|
.get(cookie)
|
||||||
|
.filter(|s| s.expires > Instant::now())
|
||||||
|
.ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "请先登录".into()))?;
|
||||||
|
let grant = accounts
|
||||||
|
.grants
|
||||||
|
.publishers
|
||||||
|
.get(&session.username)
|
||||||
|
.filter(|g| g.enabled)
|
||||||
|
.ok_or_else(|| ApiError(StatusCode::FORBIDDEN, "用户已停用".into()))?;
|
||||||
|
if admin && grant.role != Role::Admin {
|
||||||
|
return Err(ApiError(StatusCode::FORBIDDEN, "需要管理员权限".into()));
|
||||||
|
}
|
||||||
|
if change {
|
||||||
|
origin(app, headers)?;
|
||||||
|
if headers.get("x-csrf-token").and_then(|v| v.to_str().ok()) != Some(session.csrf.as_str())
|
||||||
|
{
|
||||||
|
return Err(ApiError(StatusCode::FORBIDDEN, "CSRF 校验失败".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let username = session.username.clone();
|
||||||
|
if change {
|
||||||
|
let now = Instant::now();
|
||||||
|
while accounts
|
||||||
|
.changes
|
||||||
|
.front()
|
||||||
|
.is_some_and(|t| now.duration_since(*t) >= Duration::from_secs(60))
|
||||||
|
{
|
||||||
|
accounts.changes.pop_front();
|
||||||
|
}
|
||||||
|
if accounts.changes.len() >= 60 {
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"修改请求过于频繁".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
accounts.changes.push_back(now);
|
||||||
|
}
|
||||||
|
Ok(username)
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Login {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
pub async fn register(
|
||||||
|
State(app): State<Arc<App>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<Login>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
origin(&app, &headers)?;
|
||||||
|
if !app.registration {
|
||||||
|
return Err(ApiError(StatusCode::FORBIDDEN, "注册已关闭".into()));
|
||||||
|
}
|
||||||
|
username(&input.username).map_err(bad)?;
|
||||||
|
password(&input.password).map_err(bad)?;
|
||||||
|
{
|
||||||
|
let mut accounts = app.accounts.lock().await;
|
||||||
|
rate(&mut accounts)?;
|
||||||
|
if accounts.credentials.len() >= 1000 {
|
||||||
|
return Err(bad("用户配额已满"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _permit = app
|
||||||
|
.hash_slots
|
||||||
|
.clone()
|
||||||
|
.try_acquire_owned()
|
||||||
|
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "认证服务繁忙".into()))?;
|
||||||
|
let hash = hash_password(input.password).await.map_err(internal)?;
|
||||||
|
let mut accounts = app.accounts.lock().await;
|
||||||
|
if accounts.credentials.contains_key(&input.username) {
|
||||||
|
return Err(ApiError(StatusCode::CONFLICT, "用户名不可用".into()));
|
||||||
|
}
|
||||||
|
if accounts.credentials.len() >= 1000 {
|
||||||
|
return Err(bad("用户配额已满"));
|
||||||
|
}
|
||||||
|
let mut grants = accounts.grants.clone();
|
||||||
|
grants.publishers.insert(
|
||||||
|
input.username.clone(),
|
||||||
|
Grant {
|
||||||
|
enabled: true,
|
||||||
|
role: Role::Reader,
|
||||||
|
prefixes: vec![],
|
||||||
|
ssh_key: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
atomic(
|
||||||
|
&app.ssh_config,
|
||||||
|
&serde_json::to_vec_pretty(&grants).map_err(internal)?,
|
||||||
|
0o644,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
accounts.grants = grants;
|
||||||
|
accounts
|
||||||
|
.credentials
|
||||||
|
.insert(input.username.clone(), Credential { hash });
|
||||||
|
if let Err(error) = atomic(
|
||||||
|
&app.data.join("users.json"),
|
||||||
|
&serde_json::to_vec(&accounts.credentials).map_err(internal)?,
|
||||||
|
0o600,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
accounts.credentials.remove(&input.username);
|
||||||
|
return Err(internal(error));
|
||||||
|
}
|
||||||
|
tracing::info!(action = "register", user = %input.username);
|
||||||
|
Ok(Json(json!({"username": input.username, "role": "reader"})))
|
||||||
|
}
|
||||||
|
pub async fn login(
|
||||||
|
State(app): State<Arc<App>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<Login>,
|
||||||
|
) -> ApiResult<Response> {
|
||||||
|
origin(&app, &headers)?;
|
||||||
|
username(&input.username).map_err(bad)?;
|
||||||
|
password(&input.password).map_err(bad)?;
|
||||||
|
let hash = {
|
||||||
|
let mut accounts = app.accounts.lock().await;
|
||||||
|
rate(&mut accounts)?;
|
||||||
|
accounts
|
||||||
|
.credentials
|
||||||
|
.get(&input.username)
|
||||||
|
.or_else(|| accounts.credentials.values().next())
|
||||||
|
.map(|c| c.hash.clone())
|
||||||
|
.ok_or_else(|| internal("无管理员"))?
|
||||||
|
};
|
||||||
|
let _permit = app
|
||||||
|
.hash_slots
|
||||||
|
.clone()
|
||||||
|
.try_acquire_owned()
|
||||||
|
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "认证服务繁忙".into()))?;
|
||||||
|
let verified = tokio::task::spawn_blocking(move || {
|
||||||
|
PasswordHash::new(&hash).ok().is_some_and(|h| {
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(input.password.as_bytes(), &h)
|
||||||
|
.is_ok()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
let mut accounts = app.accounts.lock().await;
|
||||||
|
if !verified
|
||||||
|
|| !accounts.credentials.contains_key(&input.username)
|
||||||
|
|| !accounts
|
||||||
|
.grants
|
||||||
|
.publishers
|
||||||
|
.get(&input.username)
|
||||||
|
.is_some_and(|g| g.enabled)
|
||||||
|
{
|
||||||
|
tracing::warn!(action = "login_failed", user = %input.username);
|
||||||
|
return Err(ApiError(StatusCode::UNAUTHORIZED, "登录失败".into()));
|
||||||
|
}
|
||||||
|
let now = Instant::now();
|
||||||
|
accounts
|
||||||
|
.sessions
|
||||||
|
.retain(|_, s| s.expires > now && s.username != input.username);
|
||||||
|
if accounts.sessions.len() >= 1000 {
|
||||||
|
return Err(bad("会话配额已满"));
|
||||||
|
}
|
||||||
|
let id = token();
|
||||||
|
let csrf = token();
|
||||||
|
accounts.sessions.insert(
|
||||||
|
id.clone(),
|
||||||
|
Session {
|
||||||
|
username: input.username.clone(),
|
||||||
|
csrf: csrf.clone(),
|
||||||
|
expires: now + Duration::from_secs(3600),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let cookie = format!(
|
||||||
|
"chublog_session={id}; Path=/; HttpOnly; SameSite=Strict; Max-Age=3600{}",
|
||||||
|
if app.secure { "; Secure" } else { "" }
|
||||||
|
);
|
||||||
|
let mut response = Json(json!({"csrf": csrf, "username": input.username, "role": accounts.grants.publishers[&input.username].role})).into_response();
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
HeaderValue::from_str(&cookie).map_err(internal)?,
|
||||||
|
);
|
||||||
|
tracing::info!(action = "login", user = %input.username);
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
pub async fn me(State(app): State<Arc<App>>, headers: HeaderMap) -> ApiResult<Json<Value>> {
|
||||||
|
let name = authorize(&app, &headers, false, false).await?;
|
||||||
|
let accounts = app.accounts.lock().await;
|
||||||
|
let csrf = accounts
|
||||||
|
.sessions
|
||||||
|
.values()
|
||||||
|
.find(|s| s.username == name)
|
||||||
|
.map(|s| &s.csrf);
|
||||||
|
Ok(Json(
|
||||||
|
json!({"username": name, "permissions": accounts.grants.publishers[&name], "csrf": csrf}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub async fn logout(State(app): State<Arc<App>>, headers: HeaderMap) -> ApiResult<Response> {
|
||||||
|
let name = authorize(&app, &headers, true, false).await?;
|
||||||
|
app.accounts
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.sessions
|
||||||
|
.retain(|_, s| s.username != name);
|
||||||
|
let mut response = StatusCode::NO_CONTENT.into_response();
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
HeaderValue::from_str(&format!(
|
||||||
|
"chublog_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0{}",
|
||||||
|
if app.secure { "; Secure" } else { "" }
|
||||||
|
))
|
||||||
|
.map_err(internal)?,
|
||||||
|
);
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
pub fn public_key(value: &str) -> Result<String> {
|
||||||
|
ensure!(
|
||||||
|
value.len() < 512 && !value.contains(['\n', '\r']),
|
||||||
|
"公钥格式无效"
|
||||||
|
);
|
||||||
|
let parts: Vec<_> = value.split_whitespace().collect();
|
||||||
|
ensure!(
|
||||||
|
parts.len() >= 2 && parts[0] == "ssh-ed25519",
|
||||||
|
"仅支持 Ed25519 公钥,禁止 authorized_keys 选项"
|
||||||
|
);
|
||||||
|
let bytes = STANDARD.decode(parts[1])?;
|
||||||
|
ensure!(
|
||||||
|
bytes.len() == 51
|
||||||
|
&& bytes[..4] == [0, 0, 0, 11]
|
||||||
|
&& &bytes[4..15] == b"ssh-ed25519"
|
||||||
|
&& bytes[15..19] == [0, 0, 0, 32],
|
||||||
|
"Ed25519 公钥内容无效"
|
||||||
|
);
|
||||||
|
Ok(format!("ssh-ed25519 {}", STANDARD.encode(bytes)))
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct KeyInput {
|
||||||
|
public_key: String,
|
||||||
|
}
|
||||||
|
pub async fn set_key(
|
||||||
|
State(app): State<Arc<App>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<KeyInput>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let name = authorize(&app, &headers, true, false).await?;
|
||||||
|
let key = public_key(&input.public_key).map_err(bad)?;
|
||||||
|
let mut accounts = app.accounts.lock().await;
|
||||||
|
if accounts
|
||||||
|
.grants
|
||||||
|
.publishers
|
||||||
|
.iter()
|
||||||
|
.any(|(user, grant)| user != &name && grant.ssh_key.as_deref() == Some(&key))
|
||||||
|
{
|
||||||
|
return Err(bad("公钥已被其他用户绑定"));
|
||||||
|
}
|
||||||
|
let mut grants = accounts.grants.clone();
|
||||||
|
grants
|
||||||
|
.publishers
|
||||||
|
.get_mut(&name)
|
||||||
|
.ok_or_else(|| bad("用户不存在"))?
|
||||||
|
.ssh_key = Some(key);
|
||||||
|
atomic(
|
||||||
|
&app.ssh_config,
|
||||||
|
&serde_json::to_vec_pretty(&grants).map_err(internal)?,
|
||||||
|
0o644,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
accounts.grants = grants;
|
||||||
|
tracing::info!(action = "ssh_key_changed", user = %name);
|
||||||
|
Ok(Json(
|
||||||
|
json!({"status": "saved", "message": "密钥已保存,发布仍需管理员授权"}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub async fn users(State(app): State<Arc<App>>, headers: HeaderMap) -> ApiResult<Json<Value>> {
|
||||||
|
authorize(&app, &headers, false, true).await?;
|
||||||
|
Ok(Json(json!(app.accounts.lock().await.grants.publishers)))
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct GrantInput {
|
||||||
|
enabled: bool,
|
||||||
|
role: Role,
|
||||||
|
prefixes: Vec<String>,
|
||||||
|
}
|
||||||
|
pub async fn grant(
|
||||||
|
State(app): State<Arc<App>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
Json(input): Json<GrantInput>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
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
|
||||||
|
.publishers
|
||||||
|
.get(&actor)
|
||||||
|
.is_some_and(|g| g.enabled && g.role == Role::Admin)
|
||||||
|
{
|
||||||
|
return Err(ApiError(StatusCode::FORBIDDEN, "管理员权限已失效".into()));
|
||||||
|
}
|
||||||
|
if !accounts.credentials.contains_key(&name) {
|
||||||
|
return Err(ApiError(StatusCode::NOT_FOUND, "用户不存在".into()));
|
||||||
|
}
|
||||||
|
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
|
||||||
|
.get_mut(&name)
|
||||||
|
.ok_or_else(|| bad("用户授权缺失"))?;
|
||||||
|
entry.enabled = input.enabled;
|
||||||
|
entry.role = input.role;
|
||||||
|
entry.prefixes = if input.role == Role::Reader {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
input.prefixes
|
||||||
|
};
|
||||||
|
atomic(
|
||||||
|
&app.ssh_config,
|
||||||
|
&serde_json::to_vec_pretty(&grants).map_err(internal)?,
|
||||||
|
0o644,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
accounts.grants = grants;
|
||||||
|
accounts
|
||||||
|
.sessions
|
||||||
|
.retain(|_, s| s.username != name || s.username == actor);
|
||||||
|
tracing::info!(action = "permission_changed", user = %name, administrator = %actor);
|
||||||
|
Ok(Json(json!({"status": "saved"})))
|
||||||
|
}
|
||||||
|
fn ensure_prefixes(prefixes: &[String]) -> Result<()> {
|
||||||
|
ensure!(
|
||||||
|
prefixes.len() <= 8
|
||||||
|
&& prefixes.iter().all(|p| (2..=64).contains(&p.len())
|
||||||
|
&& p.ends_with('-')
|
||||||
|
&& p.bytes()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')),
|
||||||
|
"前缀必须为 2–64 位小写英文、数字、短横线且以短横线结尾,最多 8 个"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PasswordInput {
|
||||||
|
old_password: String,
|
||||||
|
new_password: String,
|
||||||
|
}
|
||||||
|
pub async fn change_password(
|
||||||
|
State(app): State<Arc<App>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<PasswordInput>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let name = authorize(&app, &headers, true, false).await?;
|
||||||
|
password(&input.old_password).map_err(bad)?;
|
||||||
|
password(&input.new_password).map_err(bad)?;
|
||||||
|
let _permit = app
|
||||||
|
.hash_slots
|
||||||
|
.clone()
|
||||||
|
.try_acquire_owned()
|
||||||
|
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "认证服务繁忙".into()))?;
|
||||||
|
let previous = app
|
||||||
|
.accounts
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.credentials
|
||||||
|
.get(&name)
|
||||||
|
.ok_or_else(|| bad("用户不存在"))?
|
||||||
|
.hash
|
||||||
|
.clone();
|
||||||
|
let hash = previous.clone();
|
||||||
|
let verified = tokio::task::spawn_blocking(move || {
|
||||||
|
PasswordHash::new(&hash).ok().is_some_and(|h| {
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(input.old_password.as_bytes(), &h)
|
||||||
|
.is_ok()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
if !verified {
|
||||||
|
return Err(ApiError(StatusCode::UNAUTHORIZED, "旧口令错误".into()));
|
||||||
|
}
|
||||||
|
let hash = hash_password(input.new_password).await.map_err(internal)?;
|
||||||
|
let mut accounts = app.accounts.lock().await;
|
||||||
|
if !accounts
|
||||||
|
.grants
|
||||||
|
.publishers
|
||||||
|
.get(&name)
|
||||||
|
.is_some_and(|g| g.enabled)
|
||||||
|
{
|
||||||
|
return Err(ApiError(StatusCode::FORBIDDEN, "用户已停用".into()));
|
||||||
|
}
|
||||||
|
let credential = accounts
|
||||||
|
.credentials
|
||||||
|
.get_mut(&name)
|
||||||
|
.ok_or_else(|| bad("用户不存在"))?;
|
||||||
|
if credential.hash != previous {
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"口令已变更,请重新登录".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
credential.hash = hash;
|
||||||
|
if let Err(error) = atomic(
|
||||||
|
&app.data.join("users.json"),
|
||||||
|
&serde_json::to_vec(&accounts.credentials).map_err(internal)?,
|
||||||
|
0o600,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
accounts.credentials.get_mut(&name).unwrap().hash = previous;
|
||||||
|
return Err(internal(error));
|
||||||
|
}
|
||||||
|
accounts.sessions.retain(|_, s| s.username != name);
|
||||||
|
tracing::info!(action = "password_changed", user = %name);
|
||||||
|
Ok(Json(
|
||||||
|
json!({"status": "saved", "message": "请使用新口令重新登录"}),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
use anyhow::{Context, Result, ensure};
|
||||||
|
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::Stdio,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
|
process::Command,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MAX_MARKDOWN: usize = 256 * 1024;
|
||||||
|
pub const MAX_SNAPSHOT: usize = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Status {
|
||||||
|
Draft,
|
||||||
|
Published,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Metadata {
|
||||||
|
pub title: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub date: String,
|
||||||
|
pub updated: String,
|
||||||
|
pub status: Status,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Asset {
|
||||||
|
pub name: String,
|
||||||
|
pub data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Post {
|
||||||
|
pub meta: Metadata,
|
||||||
|
pub markdown: String,
|
||||||
|
pub assets: Vec<Asset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||||
|
pub struct Snapshot {
|
||||||
|
pub commit: String,
|
||||||
|
pub posts: BTreeMap<String, Post>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn slug(value: &str) -> Result<&str> {
|
||||||
|
ensure!(
|
||||||
|
(1..=80).contains(&value.len())
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-')
|
||||||
|
&& !value.starts_with('-')
|
||||||
|
&& !value.ends_with('-'),
|
||||||
|
"slug 仅允许 1–80 位小写英文、数字和中间短横线"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
![
|
||||||
|
"admin", "api", "posts", "tags", "archive", "rss", "sitemap", "404"
|
||||||
|
]
|
||||||
|
.contains(&value),
|
||||||
|
"slug 为保留名称"
|
||||||
|
);
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(markdown: String, assets: Vec<Asset>) -> Result<Post> {
|
||||||
|
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 decode_asset(asset: &Asset) -> Result<Vec<u8>> {
|
||||||
|
let name = &asset.name;
|
||||||
|
ensure!(
|
||||||
|
name.len() <= 100
|
||||||
|
&& !name.starts_with('.')
|
||||||
|
&& !name.contains("..")
|
||||||
|
&& name
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
|
||||||
|
&& name != "index.md",
|
||||||
|
"图片文件名无效"
|
||||||
|
);
|
||||||
|
ensure!(asset.data.len() <= 3 * 1024 * 1024, "图片超过大小限制");
|
||||||
|
let bytes = STANDARD.decode(&asset.data).context("图片编码无效")?;
|
||||||
|
ensure!(bytes.len() <= 2 * 1024 * 1024, "单张图片不得超过 2 MiB");
|
||||||
|
let format = image::guess_format(&bytes).context("无法识别图片")?;
|
||||||
|
let extension = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
|
||||||
|
ensure!(
|
||||||
|
matches!(
|
||||||
|
(format, extension.as_str()),
|
||||||
|
(image::ImageFormat::Png, "png")
|
||||||
|
| (image::ImageFormat::Jpeg, "jpg" | "jpeg")
|
||||||
|
| (image::ImageFormat::WebP, "webp")
|
||||||
|
),
|
||||||
|
"仅支持与扩展名一致的 PNG/JPEG/WebP"
|
||||||
|
);
|
||||||
|
let reader = image::ImageReader::with_format(std::io::Cursor::new(&bytes), format);
|
||||||
|
let (width, height) = reader.into_dimensions().context("图片头无效")?;
|
||||||
|
ensure!(
|
||||||
|
width > 0 && height > 0 && width <= 4096 && height <= 4096,
|
||||||
|
"图片尺寸不得超过 4096×4096"
|
||||||
|
);
|
||||||
|
image::load_from_memory_with_format(&bytes, format).context("图片内容损坏")?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn git(repo: &Path, args: &[&str], input: Option<&[u8]>) -> Result<Vec<u8>> {
|
||||||
|
let mut command = Command::new("git");
|
||||||
|
command
|
||||||
|
.arg("--no-pager")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("core.hooksPath=/dev/null")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("commit.gpgsign=false")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo)
|
||||||
|
.args(args)
|
||||||
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
.env("GIT_CONFIG_GLOBAL", "/etc/chublog/gitconfig")
|
||||||
|
.env("GIT_AUTHOR_NAME", "ChuBlog")
|
||||||
|
.env("GIT_AUTHOR_EMAIL", "blog@localhost")
|
||||||
|
.env("GIT_COMMITTER_NAME", "ChuBlog")
|
||||||
|
.env("GIT_COMMITTER_EMAIL", "blog@localhost")
|
||||||
|
.env("GIT_INDEX_FILE", repo.join("publish.index"))
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
let output = tokio::time::timeout(Duration::from_secs(15), async {
|
||||||
|
let mut child = command.spawn().context("启动 Git 失败")?;
|
||||||
|
let mut stdin = child.stdin.take().context("Git stdin")?;
|
||||||
|
let stdout = child.stdout.take().context("Git stdout")?;
|
||||||
|
let stderr = child.stderr.take().context("Git stderr")?;
|
||||||
|
let write = async {
|
||||||
|
stdin.write_all(input.unwrap_or_default()).await?;
|
||||||
|
drop(stdin);
|
||||||
|
Ok::<_, std::io::Error>(())
|
||||||
|
};
|
||||||
|
let read_out = async {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
stdout
|
||||||
|
.take(4 * 1024 * 1024 + 1)
|
||||||
|
.read_to_end(&mut out)
|
||||||
|
.await?;
|
||||||
|
Ok::<_, std::io::Error>(out)
|
||||||
|
};
|
||||||
|
let read_err = async {
|
||||||
|
let mut err = Vec::new();
|
||||||
|
stderr.take(65537).read_to_end(&mut err).await?;
|
||||||
|
Ok::<_, std::io::Error>(err)
|
||||||
|
};
|
||||||
|
let (_, stdout, stderr) = tokio::try_join!(write, read_out, read_err)?;
|
||||||
|
ensure!(
|
||||||
|
stdout.len() <= 4 * 1024 * 1024 && stderr.len() <= 65536,
|
||||||
|
"Git 输出超过限制"
|
||||||
|
);
|
||||||
|
let status = child.wait().await?;
|
||||||
|
Ok::<_, anyhow::Error>(std::process::Output {
|
||||||
|
status,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("Git 操作超时")??;
|
||||||
|
ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"Git 操作失败: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
Ok(output.stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn init(data: &Path) -> Result<PathBuf> {
|
||||||
|
tokio::fs::create_dir_all(data).await?;
|
||||||
|
let repo = data.join("content.git");
|
||||||
|
if !repo.exists() {
|
||||||
|
tokio::fs::create_dir(&repo).await?;
|
||||||
|
git(
|
||||||
|
&repo,
|
||||||
|
&["init", "--bare", "--initial-branch=main", "."],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
git(&repo, &["read-tree", "--empty"], None).await?;
|
||||||
|
let tree = git(&repo, &["write-tree"], None).await?;
|
||||||
|
let tree = String::from_utf8(tree)?;
|
||||||
|
let commit = git(
|
||||||
|
&repo,
|
||||||
|
&[
|
||||||
|
"commit-tree",
|
||||||
|
tree.trim(),
|
||||||
|
"-m",
|
||||||
|
"Initialize content repository",
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let commit = String::from_utf8(commit)?;
|
||||||
|
git(
|
||||||
|
&repo,
|
||||||
|
&["update-ref", "refs/heads/main", commit.trim()],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scan(repo: &Path) -> Result<Snapshot> {
|
||||||
|
let commit = String::from_utf8(git(repo, &["rev-parse", "refs/heads/main"], None).await?)?
|
||||||
|
.trim()
|
||||||
|
.to_owned();
|
||||||
|
scan_at(repo, &commit).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scan_at(repo: &Path, commit: &str) -> Result<Snapshot> {
|
||||||
|
let commit = commit.to_owned();
|
||||||
|
let entries = git(repo, &["ls-tree", "-rz", &commit], None).await?;
|
||||||
|
let mut grouped: BTreeMap<String, (Option<String>, Vec<Asset>)> = BTreeMap::new();
|
||||||
|
let mut total = 0;
|
||||||
|
for entry in entries.split(|b| *b == 0).filter(|e| !e.is_empty()) {
|
||||||
|
let entry = std::str::from_utf8(entry)?;
|
||||||
|
let (info, path) = entry.split_once('\t').context("Git 树格式错误")?;
|
||||||
|
let info: Vec<_> = info.split_whitespace().collect();
|
||||||
|
ensure!(
|
||||||
|
info.len() == 3 && info[0] == "100644" && info[1] == "blob",
|
||||||
|
"禁止符号链接及特殊文件"
|
||||||
|
);
|
||||||
|
let parts: Vec<_> = path.split('/').collect();
|
||||||
|
ensure!(
|
||||||
|
parts.len() == 3 && parts[0] == "posts",
|
||||||
|
"仓库包含非文章路径"
|
||||||
|
);
|
||||||
|
slug(parts[1])?;
|
||||||
|
let size = String::from_utf8(git(repo, &["cat-file", "-s", info[2]], None).await?)?
|
||||||
|
.trim()
|
||||||
|
.parse::<usize>()?;
|
||||||
|
let limit = if parts[2] == "index.md" {
|
||||||
|
MAX_MARKDOWN
|
||||||
|
} else {
|
||||||
|
2 * 1024 * 1024
|
||||||
|
};
|
||||||
|
ensure!(size <= limit, "文件超过大小限制");
|
||||||
|
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)?);
|
||||||
|
} else {
|
||||||
|
group.1.push(Asset {
|
||||||
|
name: parts[2].into(),
|
||||||
|
data: STANDARD.encode(bytes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ensure!(grouped.len() <= 1000, "最多 1000 篇文章");
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
Ok(Snapshot { commit, posts })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn persist(data: &Path, snapshot: &Snapshot) -> Result<()> {
|
||||||
|
let public = Snapshot {
|
||||||
|
commit: snapshot.commit.clone(),
|
||||||
|
posts: snapshot
|
||||||
|
.posts
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, p)| p.meta.status == Status::Published)
|
||||||
|
.map(|(k, p)| (k.clone(), p.clone()))
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
let bytes = serde_json::to_vec(&public)?;
|
||||||
|
let tmp = data.join("published.json.tmp");
|
||||||
|
let mut file = tokio::fs::File::create(&tmp).await?;
|
||||||
|
file.write_all(&bytes).await?;
|
||||||
|
file.sync_all().await?;
|
||||||
|
tokio::fs::rename(tmp, data.join("published.json")).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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}')),
|
||||||
|
"禁止控制符及双向隐藏字符"
|
||||||
|
);
|
||||||
|
for event in Parser::new_ext(body, pulldown_cmark::Options::all()) {
|
||||||
|
match event {
|
||||||
|
Event::Html(_) | Event::InlineHtml(_) => anyhow::bail!("Markdown 不允许原始 HTML"),
|
||||||
|
Event::Start(Tag::Link { dest_url, .. }) => {
|
||||||
|
ensure!(
|
||||||
|
dest_url.starts_with("https://")
|
||||||
|
|| dest_url.starts_with("http://")
|
||||||
|
|| dest_url.starts_with("mailto:")
|
||||||
|
|| dest_url.starts_with('#')
|
||||||
|
|| (dest_url.starts_with('/') && !dest_url.starts_with("//")),
|
||||||
|
"链接协议不允许"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
!dest_url.contains('\\') && !dest_url.chars().any(char::is_control),
|
||||||
|
"链接含非法字符"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Event::Start(Tag::Image { dest_url, .. }) => ensure!(
|
||||||
|
assets.iter().any(|a| a.name == dest_url.as_ref()),
|
||||||
|
"图片必须引用同文章已验证的图片文件"
|
||||||
|
),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
use crate::content::{Snapshot, Status};
|
||||||
|
use anyhow::{Context, Result, ensure};
|
||||||
|
use chusql_driver::{Client, Options, Query};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
async fn connect() -> Result<Client> {
|
||||||
|
let database = std::env::var("CHUSQL_DATABASE").context("未配置 ChuSQL")?;
|
||||||
|
ensure!(
|
||||||
|
!database.is_empty()
|
||||||
|
&& !database.starts_with(|c: char| c.is_ascii_digit())
|
||||||
|
&& database
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'_'),
|
||||||
|
"数据库名称无效"
|
||||||
|
);
|
||||||
|
let db = Client::connect_with_options(
|
||||||
|
std::env::var("CHUSQL_ADDRESS").unwrap_or_else(|_| "127.0.0.1:7777".into()),
|
||||||
|
&std::env::var("CHUSQL_USER")?,
|
||||||
|
&std::env::var("CHUSQL_PASSWORD")?,
|
||||||
|
Options {
|
||||||
|
timeout: Duration::from_secs(3),
|
||||||
|
max_response_bytes: 1024 * 1024,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
db.query(&format!("USE {database}")).await?;
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rebuild(snapshot: &Snapshot) -> Result<()> {
|
||||||
|
let db = connect().await?;
|
||||||
|
// Probe before creating: ChuSQL does not promise IF NOT EXISTS syntax.
|
||||||
|
if db
|
||||||
|
.query("SELECT slug FROM blog_posts LIMIT 1")
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
db.query("CREATE TABLE blog_posts (slug TEXT PRIMARY KEY, title TEXT, description TEXT, published_at TEXT, updated_at TEXT, path TEXT, git_commit TEXT)").await?;
|
||||||
|
}
|
||||||
|
if db
|
||||||
|
.query("SELECT slug FROM blog_tags LIMIT 1")
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
db.query("CREATE TABLE blog_tags (slug TEXT, tag TEXT, git_commit TEXT)")
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
db.query("DELETE FROM blog_tags").await?;
|
||||||
|
db.query("DELETE FROM blog_posts").await?;
|
||||||
|
for post in snapshot
|
||||||
|
.posts
|
||||||
|
.values()
|
||||||
|
.filter(|p| p.meta.status == Status::Published)
|
||||||
|
{
|
||||||
|
let meta = &post.meta;
|
||||||
|
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())
|
||||||
|
.push(", ").bind(snapshot.commit.as_str()).push(")");
|
||||||
|
db.query(q.as_str()).await?;
|
||||||
|
for tag in &meta.tags {
|
||||||
|
let q = Query::new("INSERT INTO blog_tags (slug, tag, git_commit) VALUES (")
|
||||||
|
.bind(meta.slug.as_str())
|
||||||
|
.push(", ")
|
||||||
|
.bind(tag.as_str())
|
||||||
|
.push(", ")
|
||||||
|
.bind(snapshot.commit.as_str())
|
||||||
|
.push(")");
|
||||||
|
db.query(q.as_str()).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.close().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn provision() -> Result<()> {
|
||||||
|
let db = Client::connect(
|
||||||
|
std::env::var("CHUSQL_ADDRESS").unwrap_or_else(|_| "127.0.0.1:7777".into()),
|
||||||
|
"root",
|
||||||
|
&std::env::var("CHUSQL_ROOT_PASSWORD")?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if db.query("USE chublog").await.is_err() {
|
||||||
|
db.query("CREATE DATABASE chublog").await?;
|
||||||
|
}
|
||||||
|
let roles = db.query("SHOW ROLES").await?;
|
||||||
|
ensure!(!roles.truncated, "身份列表被截断,请人工检查数据库初始化");
|
||||||
|
if !roles
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.any(|v| v.as_str() == Some("chublog_index"))
|
||||||
|
{
|
||||||
|
let password = std::env::var("CHUSQL_PASSWORD")?;
|
||||||
|
ensure!(password.len() >= 16, "数据库口令至少 16 字节");
|
||||||
|
let q = Query::new("CREATE USER chublog_index IDENTIFIED BY ").bind(password.as_str());
|
||||||
|
db.query(q.as_str())
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("创建索引用户失败"))?;
|
||||||
|
}
|
||||||
|
db.query("REVOKE CONNECT ON DATABASE chublog FROM PUBLIC")
|
||||||
|
.await?;
|
||||||
|
db.query("GRANT CONNECT, CREATE ON DATABASE chublog TO chublog_index")
|
||||||
|
.await?;
|
||||||
|
db.close().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect a lost/recreated index even if no new article was pushed.
|
||||||
|
pub async fn consistent(snapshot: &Snapshot) -> Result<bool> {
|
||||||
|
let db = connect().await?;
|
||||||
|
let result = db
|
||||||
|
.query("SELECT slug, git_commit FROM blog_posts LIMIT 1000")
|
||||||
|
.await?;
|
||||||
|
let expected: std::collections::BTreeSet<_> = snapshot
|
||||||
|
.posts
|
||||||
|
.values()
|
||||||
|
.filter(|p| p.meta.status == Status::Published)
|
||||||
|
.map(|p| (p.meta.slug.as_str(), snapshot.commit.as_str()))
|
||||||
|
.collect();
|
||||||
|
let actual: std::collections::BTreeSet<_> = result
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|row| Some((row.first()?.as_str()?, row.get(1)?.as_str()?)))
|
||||||
|
.collect();
|
||||||
|
let count = db.query("SELECT count(*) FROM blog_tags").await?;
|
||||||
|
let tags = snapshot
|
||||||
|
.posts
|
||||||
|
.values()
|
||||||
|
.filter(|p| p.meta.status == Status::Published)
|
||||||
|
.map(|p| p.meta.tags.len())
|
||||||
|
.sum::<usize>() as u64;
|
||||||
|
let matches = !result.truncated
|
||||||
|
&& result.rows.len() == expected.len()
|
||||||
|
&& actual == expected
|
||||||
|
&& count
|
||||||
|
.rows
|
||||||
|
.first()
|
||||||
|
.and_then(|row| row.first())
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
== Some(tags);
|
||||||
|
db.close().await;
|
||||||
|
Ok(matches)
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
mod accounts;
|
||||||
|
mod content;
|
||||||
|
mod index;
|
||||||
|
mod ssh;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, ensure};
|
||||||
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
|
body::Body,
|
||||||
|
extract::{DefaultBodyLimit, Path, State},
|
||||||
|
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{get, patch, post, put},
|
||||||
|
};
|
||||||
|
use base64::Engine;
|
||||||
|
use content::{Snapshot, Status};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::{
|
||||||
|
path::PathBuf,
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use tokio::sync::{Mutex, RwLock, Semaphore};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
type ApiResult<T> = std::result::Result<T, ApiError>;
|
||||||
|
struct ApiError(StatusCode, String);
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
(self.0, Json(json!({"error": self.1}))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn bad(error: impl std::fmt::Display) -> ApiError {
|
||||||
|
ApiError(StatusCode::BAD_REQUEST, error.to_string())
|
||||||
|
}
|
||||||
|
fn internal(error: impl std::fmt::Display) -> ApiError {
|
||||||
|
tracing::error!(error = %error, "operation failed");
|
||||||
|
ApiError(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"服务器操作失败,请联系管理员".into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
struct App {
|
||||||
|
data: PathBuf,
|
||||||
|
ssh_config: PathBuf,
|
||||||
|
origin: String,
|
||||||
|
secure: bool,
|
||||||
|
registration: bool,
|
||||||
|
accounts: Mutex<accounts::Accounts>,
|
||||||
|
hash_slots: Arc<Semaphore>,
|
||||||
|
snapshot: RwLock<Snapshot>,
|
||||||
|
indexed: RwLock<Option<String>>,
|
||||||
|
views: AtomicU64,
|
||||||
|
}
|
||||||
|
fn origin(app: &App, headers: &HeaderMap) -> ApiResult<()> {
|
||||||
|
if headers.get("origin").and_then(|h| h.to_str().ok()) != Some(app.origin.as_str()) {
|
||||||
|
return Err(ApiError(StatusCode::FORBIDDEN, "Origin 校验失败".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn posts(State(app): State<Arc<App>>) -> Json<Value> {
|
||||||
|
let snapshot = app.snapshot.read().await;
|
||||||
|
let mut posts: Vec<_> = snapshot
|
||||||
|
.posts
|
||||||
|
.values()
|
||||||
|
.filter(|p| p.meta.status == Status::Published)
|
||||||
|
.map(|p| &p.meta)
|
||||||
|
.collect();
|
||||||
|
posts.sort_by(|a, b| b.date.cmp(&a.date).then(a.slug.cmp(&b.slug)));
|
||||||
|
Json(json!({"commit": snapshot.commit, "posts": posts}))
|
||||||
|
}
|
||||||
|
async fn public_post(
|
||||||
|
State(app): State<Arc<App>>,
|
||||||
|
Path(key): Path<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()))?;
|
||||||
|
// 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::<Vec<_>>()}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
async fn asset(
|
||||||
|
State(app): State<Arc<App>>,
|
||||||
|
Path((key, name)): Path<(String, String)>,
|
||||||
|
) -> ApiResult<Response> {
|
||||||
|
let snapshot = app.snapshot.read().await;
|
||||||
|
let asset = snapshot
|
||||||
|
.posts
|
||||||
|
.get(&key)
|
||||||
|
.filter(|p| p.meta.status == Status::Published)
|
||||||
|
.and_then(|p| p.assets.iter().find(|a| a.name == name))
|
||||||
|
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "资源不存在".into()))?;
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(&asset.data)
|
||||||
|
.map_err(internal)?;
|
||||||
|
let mime = match name
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"png" => "image/png",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
_ => return Err(internal("invalid stored image")),
|
||||||
|
};
|
||||||
|
Ok(([(header::CONTENT_TYPE, mime)], Body::from(bytes)).into_response())
|
||||||
|
}
|
||||||
|
async fn stats(State(app): State<Arc<App>>) -> Json<Value> {
|
||||||
|
let snapshot = app.snapshot.read().await;
|
||||||
|
let posts: Vec<_> = snapshot
|
||||||
|
.posts
|
||||||
|
.values()
|
||||||
|
.filter(|p| p.meta.status == Status::Published)
|
||||||
|
.collect();
|
||||||
|
let tags: std::collections::BTreeSet<_> = posts.iter().flat_map(|p| &p.meta.tags).collect();
|
||||||
|
Json(
|
||||||
|
json!({"published_posts": posts.len(), "tags": tags.len(), "markdown_bytes": posts.iter().map(|p| p.markdown.len()).sum::<usize>(), "article_requests_since_start": app.views.load(Ordering::Relaxed)}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
async fn admin_status(State(app): State<Arc<App>>, headers: HeaderMap) -> ApiResult<Json<Value>> {
|
||||||
|
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}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
async fn sync_index(app: &App, snapshot: &Snapshot) {
|
||||||
|
match tokio::time::timeout(Duration::from_secs(30), index::rebuild(snapshot)).await {
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
*app.indexed.write().await = Some(snapshot.commit.clone());
|
||||||
|
info!(action = "index_synced", commit = %snapshot.commit);
|
||||||
|
}
|
||||||
|
Ok(Err(error)) => warn!(action = "index_pending", error = %error),
|
||||||
|
Err(_) => warn!(action = "index_pending", "rebuild timeout"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn router(app: Arc<App>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/health",
|
||||||
|
get(|| async { Json(json!({"status": "ok"})) }),
|
||||||
|
)
|
||||||
|
.route("/api/posts", get(posts))
|
||||||
|
.route("/api/posts/{slug}", get(public_post))
|
||||||
|
.route("/api/assets/{slug}/{name}", get(asset))
|
||||||
|
.route("/api/stats", get(stats))
|
||||||
|
.route("/api/auth/register", post(accounts::register))
|
||||||
|
.route("/api/auth/login", post(accounts::login))
|
||||||
|
.route("/api/auth/logout", post(accounts::logout))
|
||||||
|
.route("/api/account", get(accounts::me))
|
||||||
|
.route("/api/account/ssh-key", put(accounts::set_key))
|
||||||
|
.route("/api/account/password", put(accounts::change_password))
|
||||||
|
.route("/api/admin/users", get(accounts::users))
|
||||||
|
.route("/api/admin/users/{username}", patch(accounts::grant))
|
||||||
|
.route("/api/admin/status", get(admin_status))
|
||||||
|
.layer(DefaultBodyLimit::max(8 * 1024))
|
||||||
|
.layer(axum::middleware::from_fn(
|
||||||
|
|request: axum::extract::Request, next: axum::middleware::Next| async move {
|
||||||
|
let mut response = next.run(request).await;
|
||||||
|
for (key, value) in [
|
||||||
|
(header::CACHE_CONTROL, "no-store"),
|
||||||
|
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
|
||||||
|
(
|
||||||
|
header::CONTENT_SECURITY_POLICY,
|
||||||
|
"default-src 'none'; frame-ancestors 'none'",
|
||||||
|
),
|
||||||
|
(header::REFERRER_POLICY, "no-referrer"),
|
||||||
|
] {
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(key, HeaderValue::from_static(value));
|
||||||
|
}
|
||||||
|
response
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.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 {
|
||||||
|
content::persist(&app.data, &snapshot).await?;
|
||||||
|
info!(action = "published", commit = %snapshot.commit);
|
||||||
|
*app.snapshot.write().await = snapshot;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
let command = std::env::args().nth(1);
|
||||||
|
match command.as_deref() {
|
||||||
|
Some("ssh-gate") => {
|
||||||
|
return ssh::gate(&std::env::args().nth(2).context("缺少发布者身份")?).await;
|
||||||
|
}
|
||||||
|
Some("pre-receive") => return ssh::pre_receive().await,
|
||||||
|
Some("authorized-keys") => return ssh::authorized_keys(),
|
||||||
|
Some("provision-db") => return index::provision().await,
|
||||||
|
Some("hash-password") => {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut value = String::new();
|
||||||
|
std::io::stdin().take(1025).read_to_string(&mut value)?;
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
accounts::hash_password(value.trim_end_matches(['\r', '\n']).into()).await?
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
None | Some("rebuild-index") => {}
|
||||||
|
Some(_) => anyhow::bail!("未知命令"),
|
||||||
|
}
|
||||||
|
let data = PathBuf::from(std::env::var("DATA_DIR").unwrap_or_else(|_| "data".into()));
|
||||||
|
tokio::fs::create_dir_all(&data).await?;
|
||||||
|
let data = tokio::fs::canonicalize(data).await?;
|
||||||
|
let lock_file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.write(true)
|
||||||
|
.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?;
|
||||||
|
content::persist(&data, &snapshot).await?;
|
||||||
|
if command.as_deref() == Some("rebuild-index") {
|
||||||
|
index::rebuild(&snapshot).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let origin = std::env::var("PUBLIC_ORIGIN").context("必须设置 PUBLIC_ORIGIN")?;
|
||||||
|
let parsed: axum::http::Uri = origin.parse().context("PUBLIC_ORIGIN 无效")?;
|
||||||
|
let secure = parsed.scheme_str() == Some("https");
|
||||||
|
ensure!(
|
||||||
|
secure
|
||||||
|
|| (parsed.scheme_str() == Some("http")
|
||||||
|
&& matches!(parsed.host(), Some("localhost" | "127.0.0.1"))),
|
||||||
|
"生产环境必须使用 HTTPS"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
!origin.ends_with('/')
|
||||||
|
&& parsed.path() == "/"
|
||||||
|
&& parsed.query().is_none()
|
||||||
|
&& parsed
|
||||||
|
.authority()
|
||||||
|
.is_some_and(|a| !a.as_str().contains('@')),
|
||||||
|
"PUBLIC_ORIGIN 必须为不带末尾斜杠的站点 origin"
|
||||||
|
);
|
||||||
|
let source = PathBuf::from(
|
||||||
|
std::env::var("INGRESS_REPO").unwrap_or_else(|_| "/srv/ingress/content.git".into()),
|
||||||
|
);
|
||||||
|
ensure!(source.is_absolute(), "INGRESS_REPO 必须是绝对路径");
|
||||||
|
let ssh_config = PathBuf::from(
|
||||||
|
std::env::var("CHUBLOG_SSH_CONFIG")
|
||||||
|
.unwrap_or_else(|_| data.join("publishers.json").to_string_lossy().into_owned()),
|
||||||
|
);
|
||||||
|
let accounts = accounts::load(&data, &ssh_config, source.clone()).await?;
|
||||||
|
ensure!(
|
||||||
|
accounts.grants.repository == source,
|
||||||
|
"授权配置仓库与 INGRESS_REPO 不一致"
|
||||||
|
);
|
||||||
|
let app = Arc::new(App {
|
||||||
|
data,
|
||||||
|
ssh_config,
|
||||||
|
origin,
|
||||||
|
secure,
|
||||||
|
registration: std::env::var("ALLOW_REGISTRATION").as_deref() == Ok("true"),
|
||||||
|
accounts: Mutex::new(accounts),
|
||||||
|
hash_slots: Arc::new(Semaphore::new(2)),
|
||||||
|
snapshot: RwLock::new(snapshot),
|
||||||
|
indexed: RwLock::new(None),
|
||||||
|
views: AtomicU64::new(0),
|
||||||
|
});
|
||||||
|
let worker_app = app.clone();
|
||||||
|
let worker = tokio::spawn(async move {
|
||||||
|
let mut ticks = 0;
|
||||||
|
loop {
|
||||||
|
if let Err(error) = refresh(&worker_app, &repo, &source).await {
|
||||||
|
warn!(action = "import_pending", error = %error);
|
||||||
|
}
|
||||||
|
if ticks % 30 == 0 {
|
||||||
|
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;
|
||||||
|
} else if !matches!(
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), index::consistent(&snapshot))
|
||||||
|
.await,
|
||||||
|
Ok(Ok(true))
|
||||||
|
) {
|
||||||
|
*worker_app.indexed.write().await = None;
|
||||||
|
sync_index(&worker_app, &snapshot).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ticks += 1;
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3001".into());
|
||||||
|
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||||
|
info!(%bind, "ChuBlog API ready");
|
||||||
|
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||||
|
axum::serve(listener, router(app))
|
||||||
|
.with_graceful_shutdown(async move {
|
||||||
|
tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = terminate.recv() => {} }
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
worker.abort();
|
||||||
|
let _ = worker.await;
|
||||||
|
drop(lock_file);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
use crate::accounts::{Grant, Role};
|
||||||
|
use crate::content::{self, Snapshot};
|
||||||
|
use anyhow::{Context, Result, bail, ensure};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
os::unix::fs::MetadataExt,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::Stdio,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use tokio::{io::AsyncReadExt, process::Command};
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Config {
|
||||||
|
pub repository: PathBuf,
|
||||||
|
pub publishers: BTreeMap<String, Grant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config(path: &Path) -> Result<Config> {
|
||||||
|
let meta = std::fs::symlink_metadata(path)?;
|
||||||
|
ensure!(
|
||||||
|
meta.is_file() && (meta.uid() == 0 || meta.uid() == 10001) && meta.mode() & 0o022 == 0,
|
||||||
|
"SSH 授权配置必须为 root 或 API UID 10001 所有且不可被组或其他用户写入"
|
||||||
|
);
|
||||||
|
let bytes = std::fs::read(path)?;
|
||||||
|
ensure!(bytes.len() < 65536, "SSH 配置过大");
|
||||||
|
let cfg: Config = serde_json::from_slice(&bytes)?;
|
||||||
|
ensure!(cfg.repository.is_absolute(), "接收仓库必须为绝对路径");
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
fn authorized<'a>(cfg: &'a Config, identity: &str) -> Result<&'a Grant> {
|
||||||
|
ensure!(
|
||||||
|
!identity.is_empty()
|
||||||
|
&& identity.len() <= 64
|
||||||
|
&& identity
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b"_-".contains(&b)),
|
||||||
|
"发布者身份无效"
|
||||||
|
);
|
||||||
|
let publisher = cfg
|
||||||
|
.publishers
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
fn directory_size(path: &Path) -> Result<u64> {
|
||||||
|
let mut total = 0u64;
|
||||||
|
for entry in std::fs::read_dir(path)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let meta = entry.file_type()?;
|
||||||
|
ensure!(!meta.is_symlink(), "接收仓库不得包含符号链接");
|
||||||
|
total = total.saturating_add(if meta.is_dir() {
|
||||||
|
directory_size(&entry.path())?
|
||||||
|
} else {
|
||||||
|
entry.metadata()?.len()
|
||||||
|
});
|
||||||
|
ensure!(
|
||||||
|
total <= 256 * 1024 * 1024,
|
||||||
|
"接收仓库达到 256 MiB 配额,请管理员维护"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn gate(identity: &str) -> Result<()> {
|
||||||
|
let config_path = PathBuf::from(
|
||||||
|
std::env::var("CHUBLOG_SSH_CONFIG").unwrap_or_else(|_| "/auth/publishers.json".into()),
|
||||||
|
);
|
||||||
|
let cfg = config(&config_path)?;
|
||||||
|
authorized(&cfg, identity)?;
|
||||||
|
let original = std::env::var("SSH_ORIGINAL_COMMAND").unwrap_or_default();
|
||||||
|
ensure!(
|
||||||
|
matches!(
|
||||||
|
original.as_str(),
|
||||||
|
"git-receive-pack 'content.git'" | "git-upload-pack 'content.git'"
|
||||||
|
),
|
||||||
|
"仅允许对 content.git 执行 Git 推送或读取"
|
||||||
|
);
|
||||||
|
let receiving = original.starts_with("git-receive-pack");
|
||||||
|
directory_size(&cfg.repository)?;
|
||||||
|
let lock = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.write(true)
|
||||||
|
.open(cfg.repository.join("ssh.lock"))?;
|
||||||
|
fs2::FileExt::try_lock_exclusive(&lock).context("另一位发布者正在访问仓库,请稍后重试")?;
|
||||||
|
let executable = if receiving {
|
||||||
|
"git-receive-pack"
|
||||||
|
} else {
|
||||||
|
"git-upload-pack"
|
||||||
|
};
|
||||||
|
let mut child = Command::new(executable)
|
||||||
|
.arg(&cfg.repository)
|
||||||
|
.env_clear()
|
||||||
|
.env("PATH", "/usr/bin:/bin")
|
||||||
|
.env("HOME", "/nonexistent")
|
||||||
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||||
|
.env("CHUBLOG_SSH_CONFIG", &config_path)
|
||||||
|
.env("CHUBLOG_PUBLISHER", identity)
|
||||||
|
.env("GIT_CONFIG_COUNT", "5")
|
||||||
|
.env("GIT_CONFIG_KEY_0", "receive.maxInputSize")
|
||||||
|
.env("GIT_CONFIG_VALUE_0", "8388608")
|
||||||
|
.env("GIT_CONFIG_KEY_1", "receive.fsckObjects")
|
||||||
|
.env("GIT_CONFIG_VALUE_1", "true")
|
||||||
|
.env("GIT_CONFIG_KEY_2", "transfer.fsckObjects")
|
||||||
|
.env("GIT_CONFIG_VALUE_2", "true")
|
||||||
|
.env("GIT_CONFIG_KEY_3", "gc.auto")
|
||||||
|
.env("GIT_CONFIG_VALUE_3", "0")
|
||||||
|
.env("GIT_CONFIG_KEY_4", "core.hooksPath")
|
||||||
|
.env("GIT_CONFIG_VALUE_4", "/hooks")
|
||||||
|
.stdin(Stdio::inherit())
|
||||||
|
.stdout(Stdio::inherit())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.spawn()
|
||||||
|
.context("启动受限 Git 服务失败")?;
|
||||||
|
let status = match tokio::time::timeout(Duration::from_secs(90), child.wait()).await {
|
||||||
|
Ok(status) => status?,
|
||||||
|
Err(_) => {
|
||||||
|
child.kill().await?;
|
||||||
|
bail!("SSH Git 会话超过 90 秒限制");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ensure!(status.success(), "Git 访问失败");
|
||||||
|
drop(lock);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn validate_change(
|
||||||
|
repo: &Path,
|
||||||
|
previous: &str,
|
||||||
|
next: &str,
|
||||||
|
publisher: &Grant,
|
||||||
|
) -> Result<Snapshot> {
|
||||||
|
ensure!(
|
||||||
|
next.len() == 40
|
||||||
|
&& next.bytes().all(|b| b.is_ascii_hexdigit())
|
||||||
|
&& next != "0000000000000000000000000000000000000000",
|
||||||
|
"禁止删除主分支或无效提交"
|
||||||
|
);
|
||||||
|
let initial = previous == "0000000000000000000000000000000000000000";
|
||||||
|
let parents =
|
||||||
|
String::from_utf8(content::git(repo, &["show", "-s", "--format=%P", next], None).await?)?;
|
||||||
|
ensure!(
|
||||||
|
parents.trim() == if initial { "" } else { previous },
|
||||||
|
"每次只允许推送一个直接继承主分支的提交,首次必须为根提交;不允许合并或强推"
|
||||||
|
);
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pre_receive() -> Result<()> {
|
||||||
|
let config_path =
|
||||||
|
PathBuf::from(std::env::var("CHUBLOG_SSH_CONFIG").context("缺少受控 SSH 配置")?);
|
||||||
|
let cfg = config(&config_path)?;
|
||||||
|
let identity = std::env::var("CHUBLOG_PUBLISHER").context("拒绝未经 SSH 网关的推送")?;
|
||||||
|
let publisher = authorized(&cfg, &identity)?;
|
||||||
|
let mut input = String::new();
|
||||||
|
tokio::io::stdin()
|
||||||
|
.take(1025)
|
||||||
|
.read_to_string(&mut input)
|
||||||
|
.await?;
|
||||||
|
ensure!(input.len() <= 1024, "引用更新过多");
|
||||||
|
let lines: Vec<_> = input.lines().collect();
|
||||||
|
ensure!(lines.len() == 1, "每次只能更新主分支,禁止标签及其他引用");
|
||||||
|
let fields: Vec<_> = lines[0].split_whitespace().collect();
|
||||||
|
ensure!(
|
||||||
|
fields.len() == 3 && fields[2] == "refs/heads/main",
|
||||||
|
"仅允许 refs/heads/main"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
fields[0].len() == 40 && fields[0].bytes().all(|b| b.is_ascii_hexdigit()),
|
||||||
|
"旧提交无效"
|
||||||
|
);
|
||||||
|
validate_change(&cfg.repository, fields[0], fields[1], publisher).await?;
|
||||||
|
let latest = config(&config_path)?;
|
||||||
|
let current = authorized(&latest, &identity)?;
|
||||||
|
ensure!(
|
||||||
|
current.prefixes == publisher.prefixes && current.ssh_key == publisher.ssh_key,
|
||||||
|
"推送期间授权已变更,请重新连接"
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"ChuBlog: identity={identity} validated commit={}",
|
||||||
|
fields[1]
|
||||||
|
);
|
||||||
|
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<Snapshot> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
let snapshot = content::scan_at(repo, commit).await?;
|
||||||
|
content::git(
|
||||||
|
repo,
|
||||||
|
&["update-ref", "refs/heads/main", commit, ¤t.commit],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authorized_keys() -> Result<()> {
|
||||||
|
let path =
|
||||||
|
std::env::var("CHUBLOG_SSH_CONFIG").unwrap_or_else(|_| "/auth/publishers.json".into());
|
||||||
|
let cfg = config(Path::new(&path))?;
|
||||||
|
for (identity, grant) in &cfg.publishers {
|
||||||
|
if authorized(&cfg, identity).is_ok()
|
||||||
|
&& let Some(key) = &grant.ssh_key
|
||||||
|
{
|
||||||
|
let key = crate::accounts::public_key(key)?;
|
||||||
|
println!("restrict,command=\"/usr/local/bin/chublog-api ssh-gate {identity}\" {key}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
use super::*;
|
||||||
|
use accounts::{Grant, Role};
|
||||||
|
use axum::http::Request;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
fn markdown(key: &str, status: &str, body: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"---\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 {
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
content::git(
|
||||||
|
repo,
|
||||||
|
&[
|
||||||
|
"update-index",
|
||||||
|
"--add",
|
||||||
|
"--cacheinfo",
|
||||||
|
mode,
|
||||||
|
object.trim(),
|
||||||
|
path,
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let tree = String::from_utf8(content::git(repo, &["write-tree"], None).await.unwrap()).unwrap();
|
||||||
|
let mut args = vec!["commit-tree", tree.trim(), "-m", "Test"];
|
||||||
|
if let Some(parent) = parent {
|
||||||
|
args.extend(["-p", parent]);
|
||||||
|
}
|
||||||
|
String::from_utf8(content::git(repo, &args, None).await.unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.trim()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rejects_active_content_and_unsafe_metadata_but_keeps_inert_code() {
|
||||||
|
for body in [
|
||||||
|
"<script>alert(1)</script>",
|
||||||
|
"<img src=x onerror=alert(1)>",
|
||||||
|
"[x](javascript:alert%281%29)",
|
||||||
|
"[x](javascript:alert%281%29)",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"a\u{202e}exe",
|
||||||
|
"[x](//evil.test)",
|
||||||
|
"[x](file:///etc/passwd)",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
content::parse(markdown("alice-test", "published", body), vec![]).is_err(),
|
||||||
|
"accepted {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(content::parse(markdown("../escape", "published", "Hello"), vec![]).is_err());
|
||||||
|
assert!(
|
||||||
|
content::parse(
|
||||||
|
markdown(
|
||||||
|
"alice-test",
|
||||||
|
"published",
|
||||||
|
&"x".repeat(content::MAX_MARKDOWN)
|
||||||
|
),
|
||||||
|
vec![]
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
content::parse(
|
||||||
|
markdown(
|
||||||
|
"alice-test",
|
||||||
|
"published",
|
||||||
|
"```html\n<script>alert(1)</script>\n```\n[valid](https://example.com)"
|
||||||
|
),
|
||||||
|
vec![]
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
content::decode_asset(&content::Asset {
|
||||||
|
name: "hidden.svg".into(),
|
||||||
|
data: "PHN2Zz4=".into()
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
content::decode_asset(&content::Asset {
|
||||||
|
name: "fake.png".into(),
|
||||||
|
data: "PHNjcmlwdD4=".into()
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn git_checks_namespace_history_and_file_modes_before_publication() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let repo = content::init(dir.path()).await.unwrap();
|
||||||
|
let zero = "0000000000000000000000000000000000000000";
|
||||||
|
let first = commit(
|
||||||
|
&repo,
|
||||||
|
None,
|
||||||
|
&[(
|
||||||
|
"posts/alice-first/index.md",
|
||||||
|
"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",
|
||||||
|
"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(
|
||||||
|
&repo,
|
||||||
|
Some(&first),
|
||||||
|
&[(
|
||||||
|
"posts/alice-first/index.md",
|
||||||
|
"100644",
|
||||||
|
&markdown("alice-first", "draft", "Private"),
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.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(
|
||||||
|
&repo,
|
||||||
|
Some(&first),
|
||||||
|
&[("posts/alice-first/index.md", "120000", "/etc/passwd")],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
ssh::validate_change(&repo, &first, &link, &grant("alice-"))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let executable = commit(
|
||||||
|
&repo,
|
||||||
|
Some(&first),
|
||||||
|
&[(
|
||||||
|
"posts/alice-first/index.md",
|
||||||
|
"100755",
|
||||||
|
&markdown("alice-first", "published", "Hello"),
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
ssh::validate_change(&repo, &first, &executable, &grant("alice-"))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let hidden = commit(
|
||||||
|
&repo,
|
||||||
|
Some(&first),
|
||||||
|
&[(".github/workflows/run.yml", "100644", "bad")],
|
||||||
|
)
|
||||||
|
.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())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let public: Snapshot = serde_json::from_slice(
|
||||||
|
&tokio::fs::read(dir.path().join("published.json"))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(public.posts.is_empty(), "draft leaked into public snapshot");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn ssh_keys_cannot_inject_options_or_commands() {
|
||||||
|
let mut bytes = vec![0, 0, 0, 11];
|
||||||
|
bytes.extend(b"ssh-ed25519");
|
||||||
|
bytes.extend([0, 0, 0, 32]);
|
||||||
|
bytes.extend([42; 32]);
|
||||||
|
let key = format!(
|
||||||
|
"ssh-ed25519 {} comment",
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||||
|
);
|
||||||
|
assert!(accounts::public_key(&key).is_ok());
|
||||||
|
assert!(accounts::public_key(&format!("command=\"sh\" {key}")).is_err());
|
||||||
|
assert!(accounts::public_key(&format!("{key}\nssh-rsa AAA")).is_err());
|
||||||
|
assert!(accounts::public_key("ssh-ed25519 AAAA").is_err());
|
||||||
|
}
|
||||||
|
async fn app(dir: &std::path::Path) -> Arc<App> {
|
||||||
|
let config = dir.join("publishers.json");
|
||||||
|
let hash = accounts::hash_password("Administrator-test-123".into())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
accounts::atomic(
|
||||||
|
&dir.join("users.json"),
|
||||||
|
&serde_json::to_vec(&json!({"admin": {"hash": hash}})).unwrap(),
|
||||||
|
0o600,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let grants = ssh::Config {
|
||||||
|
repository: dir.join("ingress.git"),
|
||||||
|
publishers: BTreeMap::from([(
|
||||||
|
"admin".into(),
|
||||||
|
Grant {
|
||||||
|
enabled: true,
|
||||||
|
role: Role::Admin,
|
||||||
|
prefixes: vec![],
|
||||||
|
ssh_key: None,
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
};
|
||||||
|
accounts::atomic(&config, &serde_json::to_vec(&grants).unwrap(), 0o644)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let accounts = accounts::load(dir, &config, grants.repository)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let published = content::parse(markdown("alice-public", "published", "Hello"), vec![]).unwrap();
|
||||||
|
let draft = content::parse(markdown("alice-secret", "draft", "Secret"), vec![]).unwrap();
|
||||||
|
Arc::new(App {
|
||||||
|
data: dir.into(),
|
||||||
|
ssh_config: config,
|
||||||
|
origin: "http://localhost:4321".into(),
|
||||||
|
secure: false,
|
||||||
|
registration: true,
|
||||||
|
accounts: Mutex::new(accounts),
|
||||||
|
hash_slots: Arc::new(Semaphore::new(2)),
|
||||||
|
snapshot: RwLock::new(Snapshot {
|
||||||
|
commit: "test".into(),
|
||||||
|
posts: BTreeMap::from([
|
||||||
|
("alice-public".into(), published),
|
||||||
|
("alice-secret".into(), draft),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
indexed: RwLock::new(None),
|
||||||
|
views: AtomicU64::new(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async fn request(
|
||||||
|
app: Arc<App>,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Value,
|
||||||
|
cookie: Option<&str>,
|
||||||
|
csrf: Option<&str>,
|
||||||
|
) -> (StatusCode, HeaderMap, Value) {
|
||||||
|
let mut builder = Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri(path)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("origin", "http://localhost:4321");
|
||||||
|
if let Some(cookie) = cookie {
|
||||||
|
builder = builder.header("cookie", cookie);
|
||||||
|
}
|
||||||
|
if let Some(csrf) = csrf {
|
||||||
|
builder = builder.header("x-csrf-token", csrf);
|
||||||
|
}
|
||||||
|
let response = router(app)
|
||||||
|
.oneshot(builder.body(Body::from(body.to_string())).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let status = response.status();
|
||||||
|
let headers = response.headers().clone();
|
||||||
|
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
headers,
|
||||||
|
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn registration_login_grants_revocation_and_private_content_boundaries() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let app = app(dir.path()).await;
|
||||||
|
let (status, _, _) = request(
|
||||||
|
app.clone(),
|
||||||
|
"POST",
|
||||||
|
"/api/auth/register",
|
||||||
|
json!({"username":"alice", "password":"Alice-test-password-123", "role":"admin"}),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
let (status, _, value) = request(
|
||||||
|
app.clone(),
|
||||||
|
"POST",
|
||||||
|
"/api/auth/register",
|
||||||
|
json!({"username":"alice", "password":"Alice-test-password-123"}),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(value["role"], "reader");
|
||||||
|
let (_, headers, value) = request(
|
||||||
|
app.clone(),
|
||||||
|
"POST",
|
||||||
|
"/api/auth/login",
|
||||||
|
json!({"username":"alice", "password":"Alice-test-password-123"}),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let alice_cookie = headers[header::SET_COOKIE]
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.to_owned();
|
||||||
|
let alice_csrf = value["csrf"].as_str().unwrap().to_owned();
|
||||||
|
let (status, _, _) = request(
|
||||||
|
app.clone(),
|
||||||
|
"GET",
|
||||||
|
"/api/admin/users",
|
||||||
|
Value::Null,
|
||||||
|
Some(&alice_cookie),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||||
|
let (_, headers, value) = request(
|
||||||
|
app.clone(),
|
||||||
|
"POST",
|
||||||
|
"/api/auth/login",
|
||||||
|
json!({"username":"admin", "password":"Administrator-test-123"}),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let admin_cookie = headers[header::SET_COOKIE]
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.to_owned();
|
||||||
|
let admin_csrf = value["csrf"].as_str().unwrap().to_owned();
|
||||||
|
let input = json!({"enabled":true,"role":"publisher","prefixes":["alice-"]});
|
||||||
|
let (status, _, _) = request(
|
||||||
|
app.clone(),
|
||||||
|
"PATCH",
|
||||||
|
"/api/admin/users/alice",
|
||||||
|
input.clone(),
|
||||||
|
Some(&admin_cookie),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||||
|
let (status, _, _) = request(
|
||||||
|
app.clone(),
|
||||||
|
"PATCH",
|
||||||
|
"/api/admin/users/alice",
|
||||||
|
input,
|
||||||
|
Some(&admin_cookie),
|
||||||
|
Some(&admin_csrf),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
app.accounts.lock().await.grants.publishers["alice"].role,
|
||||||
|
Role::Publisher
|
||||||
|
);
|
||||||
|
let disk = tokio::fs::read(dir.path().join("users.json"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!String::from_utf8(disk)
|
||||||
|
.unwrap()
|
||||||
|
.contains("Alice-test-password-123")
|
||||||
|
);
|
||||||
|
let (status, _, _) = request(
|
||||||
|
app.clone(),
|
||||||
|
"POST",
|
||||||
|
"/api/auth/logout",
|
||||||
|
json!({}),
|
||||||
|
Some(&alice_cookie),
|
||||||
|
Some(&alice_csrf),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"grant change must invalidate existing sessions"
|
||||||
|
);
|
||||||
|
let (status, _, _) = request(
|
||||||
|
app.clone(),
|
||||||
|
"PATCH",
|
||||||
|
"/api/admin/users/alice",
|
||||||
|
json!({"enabled":false,"role":"reader","prefixes":[]}),
|
||||||
|
Some(&admin_cookie),
|
||||||
|
Some(&admin_csrf),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let (status, _, _) = request(
|
||||||
|
app.clone(),
|
||||||
|
"POST",
|
||||||
|
"/api/auth/login",
|
||||||
|
json!({"username":"alice", "password":"Alice-test-password-123"}),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||||
|
let (_, _, value) = request(app.clone(), "GET", "/api/posts", Value::Null, None, None).await;
|
||||||
|
assert_eq!(value["posts"].as_array().unwrap().len(), 1);
|
||||||
|
let (status, _, _) = request(
|
||||||
|
app.clone(),
|
||||||
|
"GET",
|
||||||
|
"/api/posts/alice-secret",
|
||||||
|
Value::Null,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
|
let (_, _, value) = request(app.clone(), "GET", "/api/stats", Value::Null, None, None).await;
|
||||||
|
assert_eq!(value["published_posts"], 1);
|
||||||
|
let (status, _, _) = request(
|
||||||
|
app.clone(),
|
||||||
|
"POST",
|
||||||
|
"/api/admin/posts/publish",
|
||||||
|
json!({}),
|
||||||
|
Some(&admin_cookie),
|
||||||
|
Some(&admin_csrf),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"online publishing must not exist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn password_rotation_origin_and_request_limits() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let app = app(dir.path()).await;
|
||||||
|
let response = router(app.clone())
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/auth/login")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
json!({"username":"admin","password":"Administrator-test-123"}).to_string(),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"login must validate Origin"
|
||||||
|
);
|
||||||
|
let (_, headers, value) = request(
|
||||||
|
app.clone(),
|
||||||
|
"POST",
|
||||||
|
"/api/auth/login",
|
||||||
|
json!({"username":"admin","password":"Administrator-test-123"}),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let cookie = headers[header::SET_COOKIE]
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.to_owned();
|
||||||
|
let csrf = value["csrf"].as_str().unwrap();
|
||||||
|
let (status, _, _) = request(
|
||||||
|
app.clone(),
|
||||||
|
"PUT",
|
||||||
|
"/api/account/password",
|
||||||
|
json!({"old_password":"Incorrect-password", "new_password":"New-password-12345"}),
|
||||||
|
Some(&cookie),
|
||||||
|
Some(csrf),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||||
|
let (status, _, _) = request(
|
||||||
|
app.clone(),
|
||||||
|
"PUT",
|
||||||
|
"/api/account/password",
|
||||||
|
json!({"old_password":"Administrator-test-123", "new_password":"New-password-12345"}),
|
||||||
|
Some(&cookie),
|
||||||
|
Some(csrf),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let (status, _, _) = request(
|
||||||
|
app.clone(),
|
||||||
|
"GET",
|
||||||
|
"/api/account",
|
||||||
|
Value::Null,
|
||||||
|
Some(&cookie),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||||
|
let loaded = accounts::load(dir.path(), &app.ssh_config, dir.path().join("ingress.git"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
*app.accounts.lock().await = loaded;
|
||||||
|
let (status, _, _) = request(
|
||||||
|
app.clone(),
|
||||||
|
"POST",
|
||||||
|
"/api/auth/login",
|
||||||
|
json!({"username":"admin", "password":"Administrator-test-123"}),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||||
|
let (status, _, _) = request(
|
||||||
|
app.clone(),
|
||||||
|
"POST",
|
||||||
|
"/api/auth/login",
|
||||||
|
json!({"username":"admin", "password":"New-password-12345"}),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::OK,
|
||||||
|
"password change must survive account reload"
|
||||||
|
);
|
||||||
|
let (status, _, _) = request(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/auth/register",
|
||||||
|
json!({"username":"large", "password":"x".repeat(9000)}),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
title: "第一篇文章"
|
||||||
|
slug: "welcome"
|
||||||
|
date: "2026-10-15"
|
||||||
|
updated: "2026-10-15"
|
||||||
|
status: "published"
|
||||||
|
tags: ["随笔"]
|
||||||
|
description: "从本地 Markdown 开始。"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 你好,ChuBlog
|
||||||
|
|
||||||
|
在本地编辑这篇文章,通过受限 SSH Git 推送发布。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn main() {
|
||||||
|
println!("Hello, ChuBlog!");
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
*
|
||||||
|
!Dockerfile
|
||||||
|
!bin/
|
||||||
|
!bin/chublog-api
|
||||||
|
!bin/chusql-server
|
||||||
|
!bin/csql-bootstrap
|
||||||
|
!bin/libchusql_core_storage.so
|
||||||
|
!sshd_config
|
||||||
|
!gitconfig
|
||||||
|
!authorized-keys.sh
|
||||||
|
!ssh-entrypoint.sh
|
||||||
|
!init-volumes.sh
|
||||||
|
!db-entrypoint.sh
|
||||||
|
!chusql.toml
|
||||||
|
!hooks/
|
||||||
|
!hooks/pre-receive
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{$SITE_ADDRESS:localhost} {
|
||||||
|
request_body {
|
||||||
|
max_size 8KB
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
Referrer-Policy no-referrer
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy api:3001 {
|
||||||
|
transport http {
|
||||||
|
read_timeout 15s
|
||||||
|
write_timeout 15s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
respond "ChuBlog backend is running. Frontend is planned separately." 200
|
||||||
|
}
|
||||||
|
log {
|
||||||
|
output stdout
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
FROM debian:trixie-slim AS base
|
||||||
|
ARG DEBIAN_MIRROR=deb.debian.org
|
||||||
|
RUN sed -i "s|deb.debian.org|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates libgmp10 curl util-linux && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY bin/chublog-api /usr/local/bin/chublog-api
|
||||||
|
COPY gitconfig /etc/chublog/gitconfig
|
||||||
|
RUN groupadd -g 10002 ingress && useradd -u 10001 -g ingress -M -s /usr/sbin/nologin blog
|
||||||
|
|
||||||
|
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
|
||||||
|
ENTRYPOINT ["chublog-api"]
|
||||||
|
|
||||||
|
FROM base AS ssh
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server && rm -rf /var/lib/apt/lists/* && useradd -u 10002 -g ingress -M -d /srv/ingress -s /bin/sh git && passwd -d git
|
||||||
|
COPY sshd_config /etc/ssh/sshd_config
|
||||||
|
COPY hooks /hooks
|
||||||
|
COPY ssh-entrypoint.sh /usr/local/bin/ssh-entrypoint
|
||||||
|
COPY authorized-keys.sh /usr/local/bin/chublog-authorized-keys
|
||||||
|
RUN chmod 755 /hooks/pre-receive /usr/local/bin/ssh-entrypoint /usr/local/bin/chublog-authorized-keys
|
||||||
|
ENTRYPOINT ["/usr/local/bin/ssh-entrypoint"]
|
||||||
|
|
||||||
|
FROM base AS init
|
||||||
|
COPY init-volumes.sh /usr/local/bin/init-volumes
|
||||||
|
RUN chmod 755 /usr/local/bin/init-volumes
|
||||||
|
ENTRYPOINT ["/usr/local/bin/init-volumes"]
|
||||||
|
|
||||||
|
FROM debian:trixie-slim AS database
|
||||||
|
ARG DEBIAN_MIRROR=deb.debian.org
|
||||||
|
RUN sed -i "s|deb.debian.org|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends libgmp10 ca-certificates && rm -rf /var/lib/apt/lists/* && useradd -u 10003 -M -s /usr/sbin/nologin chusql
|
||||||
|
COPY bin/chusql-server bin/csql-bootstrap bin/libchusql_core_storage.so /usr/local/bin/
|
||||||
|
COPY chusql.toml /etc/chusql/settings.toml
|
||||||
|
COPY db-entrypoint.sh /usr/local/bin/db-entrypoint
|
||||||
|
RUN chmod 755 /usr/local/bin/db-entrypoint
|
||||||
|
ENV LD_LIBRARY_PATH=/usr/local/bin
|
||||||
|
USER 10003:10003
|
||||||
|
ENTRYPOINT ["/usr/local/bin/db-entrypoint"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
exec /usr/local/bin/chublog-api authorized-keys
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[storage]
|
||||||
|
data_dir = "/var/lib/chusql"
|
||||||
|
log_files = "/var/log/chusql"
|
||||||
|
[server]
|
||||||
|
listen_host = "0.0.0.0"
|
||||||
|
port = 7777
|
||||||
|
max_message = 1048576
|
||||||
|
max_rows = 1000
|
||||||
|
sudo_auth_user = ""
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
name: chublog
|
||||||
|
x-logging: &logging
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
services:
|
||||||
|
init:
|
||||||
|
build: { context: ., target: init, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}" } }
|
||||||
|
volumes:
|
||||||
|
- content:/data
|
||||||
|
- authorization:/auth
|
||||||
|
- ingress:/srv/ingress
|
||||||
|
- database:/db-data
|
||||||
|
- db-logs:/db-logs
|
||||||
|
restart: "no"
|
||||||
|
network_mode: none
|
||||||
|
logging: *logging
|
||||||
|
api:
|
||||||
|
build: { context: ., target: api, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}" } }
|
||||||
|
depends_on:
|
||||||
|
init: { condition: service_completed_successfully }
|
||||||
|
environment:
|
||||||
|
PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:?Set PUBLIC_ORIGIN}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:?Set ADMIN_PASSWORD_HASH}
|
||||||
|
ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false}
|
||||||
|
CHUSQL_ADDRESS: db:7777
|
||||||
|
CHUSQL_USER: chublog_index
|
||||||
|
CHUSQL_PASSWORD: ${CHUSQL_PASSWORD:?Set CHUSQL_PASSWORD}
|
||||||
|
CHUSQL_DATABASE: chublog
|
||||||
|
volumes:
|
||||||
|
- content:/data
|
||||||
|
- authorization:/auth
|
||||||
|
- ingress:/srv/ingress:ro
|
||||||
|
read_only: true
|
||||||
|
tmpfs: ["/tmp:size=16m,noexec,nosuid"]
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
pids_limit: 128
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: 1.0
|
||||||
|
networks: [backend]
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD, curl, -fsS, http://127.0.0.1:3001/api/health]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
logging: *logging
|
||||||
|
ssh:
|
||||||
|
build: { context: ., target: ssh, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}" } }
|
||||||
|
depends_on:
|
||||||
|
init: { condition: service_completed_successfully }
|
||||||
|
api: { condition: service_healthy }
|
||||||
|
environment:
|
||||||
|
CHUBLOG_SSH_CONFIG: /auth/publishers.json
|
||||||
|
ports: ["${SSH_BIND:-0.0.0.0}:${SSH_PORT:-2222}:2222"]
|
||||||
|
volumes:
|
||||||
|
- ingress:/srv/ingress
|
||||||
|
- authorization:/auth:ro
|
||||||
|
- ssh-host-keys:/host-keys
|
||||||
|
read_only: true
|
||||||
|
tmpfs: ["/run:size=8m,noexec,nosuid", "/tmp:size=16m,noexec,nosuid"]
|
||||||
|
cap_drop: [ALL]
|
||||||
|
cap_add: [SETUID, SETGID, SYS_CHROOT]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
pids_limit: 64
|
||||||
|
mem_limit: 256m
|
||||||
|
cpus: 1.0
|
||||||
|
networks: [ssh]
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *logging
|
||||||
|
db:
|
||||||
|
build: { context: ., target: database, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}" } }
|
||||||
|
depends_on:
|
||||||
|
init: { condition: service_completed_successfully }
|
||||||
|
environment:
|
||||||
|
CHUSQL_ROOT_PASSWORD: ${CHUSQL_ROOT_PASSWORD:?Set CHUSQL_ROOT_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- database:/var/lib/chusql
|
||||||
|
- db-logs:/var/log/chusql
|
||||||
|
read_only: true
|
||||||
|
tmpfs: ["/tmp:size=16m,noexec,nosuid"]
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
pids_limit: 64
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: 1.0
|
||||||
|
networks: [backend]
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *logging
|
||||||
|
db-init:
|
||||||
|
build: { context: ., target: api, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}" } }
|
||||||
|
profiles: [setup]
|
||||||
|
entrypoint: [chublog-api, provision-db]
|
||||||
|
environment:
|
||||||
|
CHUSQL_ADDRESS: db:7777
|
||||||
|
CHUSQL_ROOT_PASSWORD: ${CHUSQL_ROOT_PASSWORD:?Set CHUSQL_ROOT_PASSWORD}
|
||||||
|
CHUSQL_PASSWORD: ${CHUSQL_PASSWORD:?Set CHUSQL_PASSWORD}
|
||||||
|
depends_on: [db]
|
||||||
|
networks: [backend]
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
read_only: true
|
||||||
|
restart: "no"
|
||||||
|
logging: *logging
|
||||||
|
caddy:
|
||||||
|
image: caddy:2
|
||||||
|
environment:
|
||||||
|
SITE_ADDRESS: ${SITE_ADDRESS:-localhost}
|
||||||
|
ports: ["${HTTP_BIND:-0.0.0.0}:${HTTP_PORT:-80}:80", "${HTTP_BIND:-0.0.0.0}:${HTTPS_PORT:-443}:443"]
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
- caddy-config:/config
|
||||||
|
cap_drop: [ALL]
|
||||||
|
cap_add: [NET_BIND_SERVICE]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
networks: [backend, edge]
|
||||||
|
depends_on:
|
||||||
|
api: { condition: service_healthy }
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: *logging
|
||||||
|
networks:
|
||||||
|
backend: { internal: true }
|
||||||
|
ssh: {}
|
||||||
|
edge: {}
|
||||||
|
volumes:
|
||||||
|
content: {}
|
||||||
|
ingress: {}
|
||||||
|
authorization: {}
|
||||||
|
ssh-host-keys: {}
|
||||||
|
database: {}
|
||||||
|
db-logs: {}
|
||||||
|
caddy-data: {}
|
||||||
|
caddy-config: {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
if [ ! -d /var/lib/chusql/system ]; then
|
||||||
|
printf '%s\n' "$CHUSQL_ROOT_PASSWORD" | csql-bootstrap --config /etc/chusql/settings.toml --user root --password-stdin
|
||||||
|
fi
|
||||||
|
unset CHUSQL_ROOT_PASSWORD
|
||||||
|
exec chusql-server --config /etc/chusql/settings.toml --listen-host 0.0.0.0 --port 7777
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[safe]
|
||||||
|
directory = /srv/ingress/content.git
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
exec /usr/local/bin/chublog-api pre-receive
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
mkdir -p /data /auth /srv/ingress /db-data /db-logs
|
||||||
|
chown 10001:10002 /data /auth
|
||||||
|
chmod 700 /data
|
||||||
|
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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
mkdir -p /run/sshd
|
||||||
|
if [ ! -f /host-keys/ssh_host_ed25519_key ]; then
|
||||||
|
ssh-keygen -q -t ed25519 -N '' -f /host-keys/ssh_host_ed25519_key
|
||||||
|
fi
|
||||||
|
exec /usr/sbin/sshd -D -e
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
Port 2222
|
||||||
|
ListenAddress 0.0.0.0
|
||||||
|
HostKey /host-keys/ssh_host_ed25519_key
|
||||||
|
PidFile /run/sshd.pid
|
||||||
|
PermitRootLogin no
|
||||||
|
PasswordAuthentication no
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
|
PermitEmptyPasswords no
|
||||||
|
PubkeyAuthentication yes
|
||||||
|
AuthenticationMethods publickey
|
||||||
|
PubkeyAcceptedAlgorithms ssh-ed25519
|
||||||
|
AllowUsers git
|
||||||
|
AuthorizedKeysFile none
|
||||||
|
AuthorizedKeysCommand /usr/local/bin/chublog-authorized-keys
|
||||||
|
AuthorizedKeysCommandUser root
|
||||||
|
AllowAgentForwarding no
|
||||||
|
AllowTcpForwarding no
|
||||||
|
AllowStreamLocalForwarding no
|
||||||
|
GatewayPorts no
|
||||||
|
PermitTunnel no
|
||||||
|
X11Forwarding no
|
||||||
|
PermitTTY no
|
||||||
|
PermitUserEnvironment no
|
||||||
|
PermitUserRC no
|
||||||
|
DisableForwarding yes
|
||||||
|
MaxAuthTries 3
|
||||||
|
MaxSessions 1
|
||||||
|
MaxStartups 3:50:10
|
||||||
|
LoginGraceTime 20
|
||||||
|
ClientAliveInterval 15
|
||||||
|
ClientAliveCountMax 3
|
||||||
|
LogLevel VERBOSE
|
||||||
|
UsePAM no
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# 后端 API
|
||||||
|
|
||||||
|
全部正文使用 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/assets/:slug/:name` | 公共,仅已发布文章的已验证图片 |
|
||||||
|
| GET | `/api/stats` | 公共,文章数、标签数、正文源字节数、启动以来文章请求数 |
|
||||||
|
| POST | `/api/auth/register` | 公共 + Origin;`{username,password}`,创建 reader;可配置关闭 |
|
||||||
|
| POST | `/api/auth/login` | 公共 + Origin;`{username,password}`,返回 `{username,role,csrf}` 及 Cookie |
|
||||||
|
| POST | `/api/auth/logout` | 登录 + Origin + CSRF;使会话失效并清除 Cookie |
|
||||||
|
| GET | `/api/account` | 登录;返回自己的 `{username,permissions,csrf}`,不含口令哈希 |
|
||||||
|
| PUT | `/api/account/ssh-key` | 登录 + Origin + CSRF;`{public_key}`,替换自己的唯一 Ed25519 公钥 |
|
||||||
|
| PUT | `/api/account/password` | 登录 + Origin + CSRF;`{old_password,new_password}`,成功后全部会话失效 |
|
||||||
|
| GET | `/api/admin/users` | 管理员;返回用户启用状态、角色、前缀及公钥,不含哈希 |
|
||||||
|
| 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` 等额外字段。公钥不能与别的用户重复。
|
||||||
|
|
||||||
|
修改请求的头为 `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 发布。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# ADR-001:SSH Git 是当前唯一文章写入口
|
||||||
|
|
||||||
|
## 状态与日期
|
||||||
|
|
||||||
|
已接受,2026-10-15。以用户后续补充需求为准,调整 PLAN.md 的后台先行方案。
|
||||||
|
|
||||||
|
## 决定
|
||||||
|
|
||||||
|
当前只做 Rust/Axum 后端。网站账户注册后为 reader;公钥登记和管理员发布授权分别完成。OpenSSH 使用固定 `AuthorizedKeysCommand` 动态读取独立授权文件,为每把允许的公钥返回绑定站内用户名的强制命令。OS 只有一个无交互权限的 git 账户,网站用户不能自行决定命令或仓库路径。
|
||||||
|
|
||||||
|
接收仓库与正式内容仓库分属不同进程身份。pre-receive 在 Git quarantine 内验证全部候选文件、单提交历史和改动命名空间;API 再验证并导入独立正式仓库,从该 Git 生成已发布快照。ChuSQL 只保存可重建的公开文章元数据与标签,HTTP 读取不依赖数据库。
|
||||||
|
|
||||||
|
账户口令哈希保存于 API 私有文件,发布权限/公钥保存于独立只读给 SSH 的 JSON 文件;授权原子替换,SSH 每次认证与接收均重新检查。密码不进入 ChuSQL、文章 Git 或 SSH 容器。该选择避免让实验数据库承担不可重建身份数据,也避免把密码哈希暴露给 SSH 进程。
|
||||||
|
|
||||||
|
## 取舍
|
||||||
|
|
||||||
|
暂不提供在线文章编辑入口,避免两条路径同时修改内容。先用 slug 前缀做权限边界,不引入复杂角色体系。单次只接收一个线性提交,便于验证历史,不允许将危险中间提交夹带进仓库。所有授权发布者共享读取仓库历史,草稿不对作者之间隔离。
|
||||||
|
|
||||||
|
文件账户存储适合单实例、最多 1000 个用户的本轮实现。用户文件与权限文件需要一起备份;不支持多副本账号写入。注册中途失败可能留下没有凭据的 reader 授权条目,但不能登录或发布,重试注册可覆盖。发布授权变更仅涉及单份权限文件,不存在哈希与权限双写导致的旧发布权残留。
|
||||||
|
|
||||||
|
未来引入前端、在线编辑、多实例或更强审核时应单独记录新决策,并继续保留唯一发布协调点。
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the API and copy existing local ChuSQL release artifacts into the Docker context."""
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--no-build', action='store_true', help='use an existing API release binary')
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.no_build:
|
||||||
|
subprocess.run(['cargo', 'build', '--locked', '--release'], cwd=root, check=True)
|
||||||
|
out = root / 'deploy' / 'bin'
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(root / 'target/release/chublog-api', out / 'chublog-api')
|
||||||
|
chusql = root.parent / 'ChuSQL'
|
||||||
|
for package, executable in [('chusql-server', 'chusql-server'), ('chusql-bootstrap', 'csql-bootstrap')]:
|
||||||
|
matches = list((chusql / package / '.stack-work/install').glob(f'*/**/bin/{executable}'))
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise SystemExit(f'Expected one built {executable}, found {len(matches)}; build {package} first using its stack.yaml.')
|
||||||
|
shutil.copy2(matches[0], out / executable)
|
||||||
|
shutil.copy2(chusql / 'chusql-core/storage/target/release/libchusql_core_storage.so', out)
|
||||||
|
print('Prepared deploy/bin. Build and run Compose from this same Linux architecture (Debian 13).')
|
||||||
Executable
+160
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Exercise real Docker, SSH, Git, HTTP authorization, database outage, and restart recovery.
|
||||||
|
Uses an isolated Compose project and removes only that project's containers/volumes.
|
||||||
|
Run prepare-deploy.py first. Ports 18080, 18443, 12222 must be free.
|
||||||
|
"""
|
||||||
|
import http.cookiejar
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ORIGIN = 'http://localhost:18080'
|
||||||
|
|
||||||
|
|
||||||
|
def run(args, **kwargs):
|
||||||
|
return subprocess.run(args, check=True, text=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for(check, timeout=90):
|
||||||
|
end = time.monotonic() + timeout
|
||||||
|
last = None
|
||||||
|
while time.monotonic() < end:
|
||||||
|
try:
|
||||||
|
value = check()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
except (OSError, ValueError, urllib.error.HTTPError, AssertionError) as error:
|
||||||
|
last = error
|
||||||
|
time.sleep(1)
|
||||||
|
raise AssertionError(f'Timed out: {last}')
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self):
|
||||||
|
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
||||||
|
self.csrf = None
|
||||||
|
|
||||||
|
def call(self, method, path, data=None, expected=200):
|
||||||
|
headers = {'Origin': ORIGIN, 'Content-Type': 'application/json'}
|
||||||
|
if self.csrf:
|
||||||
|
headers['X-CSRF-Token'] = self.csrf
|
||||||
|
req = urllib.request.Request(ORIGIN + path, method=method, headers=headers,
|
||||||
|
data=json.dumps(data).encode() if data is not None else None)
|
||||||
|
try:
|
||||||
|
response = self.opener.open(req, timeout=10)
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
assert error.code == expected, (path, error.code, error.read())
|
||||||
|
return None
|
||||||
|
assert response.status == expected, (path, response.status)
|
||||||
|
body = response.read()
|
||||||
|
return json.loads(body) if body else None
|
||||||
|
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary:
|
||||||
|
tmp = Path(temporary)
|
||||||
|
password = secrets.token_urlsafe(24)
|
||||||
|
hashed = run([str(ROOT / 'deploy/bin/chublog-api'), 'hash-password'], input=password, capture_output=True).stdout.strip()
|
||||||
|
envfile = tmp / '.env'
|
||||||
|
envfile.write_text(f"PUBLIC_ORIGIN={ORIGIN}\nSITE_ADDRESS=http://localhost:80\nADMIN_PASSWORD_HASH='{hashed}'\n"
|
||||||
|
f"CHUSQL_ROOT_PASSWORD={secrets.token_urlsafe(24)}\nCHUSQL_PASSWORD={secrets.token_urlsafe(24)}\n"
|
||||||
|
'DEBIAN_MIRROR=mirrors.ustc.edu.cn\nALLOW_REGISTRATION=true\nHTTP_BIND=127.0.0.1\nHTTP_PORT=18080\nHTTPS_PORT=18443\nSSH_BIND=127.0.0.1\nSSH_PORT=12222\n')
|
||||||
|
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')]
|
||||||
|
try:
|
||||||
|
run(compose + ['up', '-d', '--build'])
|
||||||
|
admin = Client()
|
||||||
|
wait_for(lambda: admin.call('GET', '/api/health'))
|
||||||
|
wait_for(lambda: subprocess.run(compose + ['run', '--rm', 'db-init'], capture_output=True).returncode == 0)
|
||||||
|
admin.csrf = admin.call('POST', '/api/auth/login', {'username': 'admin', 'password': password})['csrf']
|
||||||
|
publisher = Client()
|
||||||
|
publisher.call('POST', '/api/auth/register', {'username': 'alice', 'password': password})
|
||||||
|
publisher.csrf = publisher.call('POST', '/api/auth/login', {'username': 'alice', 'password': password})['csrf']
|
||||||
|
run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', str(tmp / 'key')])
|
||||||
|
publisher.call('PUT', '/api/account/ssh-key', {'public_key': (tmp / 'key.pub').read_text().strip()})
|
||||||
|
publisher.call('GET', '/api/admin/users', expected=403)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['GIT_SSH_COMMAND'] = f'ssh -i {tmp / "key"} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile={tmp / "known_hosts"} -p 12222'
|
||||||
|
local = tmp / 'articles'
|
||||||
|
run(['git', 'init', '--initial-branch=main', str(local)], capture_output=True)
|
||||||
|
run(['git', '-C', str(local), 'config', 'user.name', 'Alice'])
|
||||||
|
run(['git', '-C', str(local), 'config', 'user.email', 'alice@example.invalid'])
|
||||||
|
run(['git', '-C', str(local), 'remote', 'add', 'origin', 'git@localhost:content.git'])
|
||||||
|
article = local / 'posts/alice-first/index.md'
|
||||||
|
article.parent.mkdir(parents=True)
|
||||||
|
original = (ROOT / 'content/posts/welcome/index.md').read_text().replace('slug: "welcome"', 'slug: "alice-first"')
|
||||||
|
article.write_text(original)
|
||||||
|
|
||||||
|
def commit():
|
||||||
|
run(['git', '-C', str(local), 'add', '.'])
|
||||||
|
run(['git', '-C', str(local), 'commit', '-m', 'Update article'], capture_output=True)
|
||||||
|
|
||||||
|
def push(success=True, *extra):
|
||||||
|
result = subprocess.run(['git', '-C', str(local), 'push', 'origin', *extra] if extra else ['git', '-C', str(local), 'push', '-u', 'origin', 'main'], env=env, capture_output=True, text=True)
|
||||||
|
assert (result.returncode == 0) == success, result.stderr
|
||||||
|
return result
|
||||||
|
|
||||||
|
commit()
|
||||||
|
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: (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')
|
||||||
|
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']:
|
||||||
|
if kind == 'script':
|
||||||
|
article.write_text(original + '\n<script>alert(1)</script>\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 == 'oversize':
|
||||||
|
article.write_text(original + 'x' * (257 * 1024))
|
||||||
|
else:
|
||||||
|
(local / 'oversized.bin').write_bytes(os.urandom(9 * 1024 * 1024))
|
||||||
|
commit()
|
||||||
|
push(False)
|
||||||
|
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')
|
||||||
|
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)
|
||||||
|
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'
|
||||||
|
run(compose + ['restart', 'api'])
|
||||||
|
wait_for(lambda: Client().call('GET', '/api/posts/alice-first')['meta']['slug'] == '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')
|
||||||
|
commit()
|
||||||
|
push(False)
|
||||||
|
run(compose + ['start', 'db'])
|
||||||
|
wait_for(lambda: (lambda s: s['indexed_commit'] == s['commit'])(admin.call('GET', '/api/admin/status')), timeout=120)
|
||||||
|
print('PASS: draft privacy, database outage, API restart recovery, revocation, automatic index recovery')
|
||||||
|
except Exception:
|
||||||
|
run(compose + ['logs', '--tail', '40'])
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
run(compose + ['down', '--volumes', '--remove-orphans'])
|
||||||
Reference in new issue
Block a user