Compare commits

...
8 Commits
Author SHA1 Message Date
wangairnan 412821684b docs: 新增第 2 轮评审报告、D36 决策记录与 serve 旧词收尾
review-round2.md 全量修复清单;decisions.md 澄清 D6、新增 D36;design/acceptance 残留 serve → start(P2-14)。
2026-09-08 17:32:57 +08:00
wangairnan b94e9b1721 fix(web): 修复图片上传、自动保存竞态、登录竞态并统一失效公共缓存
FormData 不强设 Content-Type(P1-2),补 api 客户端回归测试; 自动保存成功仅失效列表与公共键,防详情 refetch 回滚输入(P1-4); login/logout 以请求序号丢弃迟到 /api/me 响应,401 统一回调跳登录(P2-15); 首页不硬编码 page_size,写操作后统一 invalidatePublic,TagPage 补 error 分支(P2-15)。
2026-09-08 17:32:57 +08:00
wangairnan 3105b0415c fix(middleware): Origin 同源校验增加 scheme 比对
http/https 不再视为同源;可信反代后采信 X-Forwarded-Proto(round2 P2-11),补测试。
2026-09-08 17:32:57 +08:00
wangairnan 1272d680a8 fix(httpapi): no-store 接线、图片缓存头时序、解压炸弹上限与 RSS 空日期
/api/admin/* 挂 NoStore、/api/me 内联 no-store(P1-3); 图片缓存头移至数据读取成功后,404 不携带 public immutable(P2-9); 上传先 DecodeConfig 限制像素 ≤2^25 再解码(P2-12); 无公开笔记时省略 lastBuildDate(P2-10)。
2026-09-08 17:32:52 +08:00
wangairnan ba7d5dd01f fix(auth): VerifyPassword 校验 PHC 参数上下界
t/p=0 会使 argon2.IDKey panic、m 超大可 OOM;越界一律返回 false(round2 P2-8),补畸形参数用例。
2026-09-08 17:32:52 +08:00
wangairnan a35e7fdfd3 fix(cmd): 维护子命令改经 OpenData 打开,--allow-newer 仅限 start/init
passwd/backup/gc 不再触发迁移与 user_version 守卫,回滚旧二进制场景备份仍可用(round2 P1-5,落实 D6)。
2026-09-08 17:32:52 +08:00
wangairnan 132c4a1ba2 fix(store): 修正邻接查询、GC 竞态、备份权限与设置解析,新增 OpenData 纯数据打开
邻接查询 tie-break 与 pinned 排序键与列表序对齐,补表驱动测试(round2 P1-1); GC DELETE 带条件复查,防 SELECT 与 DELETE 间恢复/引用竞态(P2-6); 备份经 umask 收紧创建即 0600,消除 chmod 前暴露窗口(P2-7); page_size 改 strconv.Atoi 全文解析,拒部分解析脏值(P2-13); OpenData 不迁移、不做版本守卫(D6/P1-5)。
2026-09-08 17:32:47 +08:00
wangairnan a885b52f18 docs: 新增提交规范(Conventional Commits)并链接至 README
明确 type/scope/subject/body/footer 规则与提交拆分约定;README 文档索引加入链接。
2026-09-08 17:32:44 +08:00
33 changed files with 763 additions and 66 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
极简高安全私人笔记 + 博客。最终交付 = **一个二进制程序 + 一个数据文件(目录)**:
无数据库服务、无缓存服务、无 Node 运行时、无外部依赖。
> 设计文档:[docs/design.md](docs/design.md)(v1.1);实施决策记录:[docs/decisions.md](docs/decisions.md)
> 设计文档:[docs/design.md](docs/design.md)(v1.1);实施决策记录:[docs/decisions.md](docs/decisions.md);提交规范:[docs/contributing.md](docs/contributing.md)
## 功能
+3 -3
View File
@@ -238,7 +238,7 @@ func runResetPassword(args []string) error {
if err != nil {
return err
}
st, err := store.Open(cfg.DBPath(), false)
st, err := store.OpenData(cfg.DBPath())
if err != nil {
return err
}
@@ -313,7 +313,7 @@ func runBackup(args []string) error {
if dest == "" {
dest = filepath.Join(cfg.DataDir, fmt.Sprintf("pn-backup-%s.db", time.Now().Format("20060102-150405")))
}
st, err := store.Open(cfg.DBPath(), false)
st, err := store.OpenData(cfg.DBPath())
if err != nil {
return err
}
@@ -337,7 +337,7 @@ func runGC(args []string) error {
commit = true
}
}
st, err := store.Open(cfg.DBPath(), false)
st, err := store.OpenData(cfg.DBPath())
if err != nil {
return err
}
+1 -1
View File
@@ -50,7 +50,7 @@
| 里程碑 | 验收门 | 状态 |
| --- | --- | --- |
| M0 骨架 | `make build` 产出可运行二进制;serve 起服务;/api/health 200 | ✅ |
| M0 骨架 | `make build` 产出可运行二进制;start 起服务;/api/health 200 | ✅ |
| M1 公开浏览 | 笔记/标签 API + 博客三页 + 渲染管线 + RSS/sitemap + meta 注入;公开侧可见性用例;构建冒烟 | ✅ |
| M2 管理端 | 登录/会话/CSRF + CRUD + 回收站 + 图片上传 + 编辑器 + 改密;完整矩阵 + 各组用例随功能交付 | ✅ |
| M3 安全加固 | §9.3 全项通过(见上表);CSP nonce 硬化按设计留 M3+ 待办 | ✅ |
+75
View File
@@ -0,0 +1,75 @@
# 提交规范(Conventional Commits)
本仓库提交信息遵循 [Conventional Commits 1.0.0](https://www.conventionalcommits.org/zh-hans/v1.0.0/),
格式如下:
```
<type>(<scope>): <subject>
<body>
<footer>
```
## type(必填)
| type | 用途 |
| --- | --- |
| `feat` | 新功能(用户可感知的行为变化) |
| `fix` | 缺陷修复(bug、安全漏洞、评审发现的功能缺陷) |
| `docs` | 仅文档(设计/决策/评审报告/README/注释性文档) |
| `refactor` | 重构(不改行为、不修缺陷的代码整理) |
| `perf` | 性能优化 |
| `test` | 仅测试代码(新增/修正用例与测试基建) |
| `build` | 构建系统或外部依赖(Makefile、go.mod、npm、打包脚本) |
| `ci` | 持续集成配置 |
| `chore` | 其他杂项(不触及 src 与 test) |
| `revert` | 回滚某次提交(subject 为被回滚提交的摘要,footer 注明 hash) |
## scope(可选)
按模块/目录取值,多个模块的横切改动可省略 scope 或在 body 中说明:
`cmd` / `config` / `store` / `auth` / `markdown` / `middleware` / `httpapi` / `webui` / `web` / `deploy` / `docs`
## subject 规则
- 中文,动词开头(如「修正」「新增」「移除」「收尾」);
- 不超过 50 字;
- 不加句号;描述「做了什么」,而非「做了什么活」。
## body(可选)
- 一行一要点,说明「为什么改」与「影响范围」;
- 引用来源:评审条目(如 `round2 P1-2`)、设计章节(如 `§9.2`)、issue 编号。
## footer(可选)
- `BREAKING CHANGE:` 开头(或 type 后加 `!`)注明破坏性变更;
- 回滚注明被回滚提交的 hash。
## 示例
```
fix(store): 修正上一篇/下一篇邻接查询 tie-break 与 pinned 排序键
同秒多篇更新时 id 比较方向与列表序相反导致漏链/错链;
邻接语义纳入 pinned,与 ListPublicNotes 排序键一致。
round2 P1-1
```
```
feat(cmd): 新增 passwd 子命令免旧口令重设管理员口令
重设成功后吊销全部会话;口令经 PN_ADMIN_PASSWORD 或交互输入。
```
```
docs: 新增提交规范并链接至 README
```
## 提交拆分
- 一个提交只做一件事(文档、功能、修复、测试分开);
- 修复缺陷时**同提交**附带其回归测试;
- 不提交构建产物、密钥与本地配置(`pn` 二进制、`data/`、`.env` 等已在 `.gitignore`)。
+3 -2
View File
@@ -18,7 +18,7 @@
| # | 决策 | 依据 |
| --- | --- | --- |
| D6 | `backup` / `gc` 子命令**不执行迁移、不做 user_version 守卫**(只有 `serve`/`init` 迁移) | 维护命令应是纯数据操作:在陌生(更新)schema 上跑迁移反而危险。设计 §10.4 的守卫语境是「启动服务」 |
| D6 | `backup` / `gc` / `passwd` 子命令**不执行迁移、不做 user_version 守卫**(只有 `start`/`init` 迁移) | 维护命令应是纯数据操作:在陌生(更新)schema 上跑迁移反而危险。设计 §10.4 的守卫语境是「启动服务」 |
| D7 | `init` 在口令已存在时**拒绝并提示走管理界面改密**(无 `--force`) | 防止误操作覆盖口令;单管理员场景下改密有专门界面(§7.1 POST /api/admin/password) |
| D8 | `GET /api/site` 公开端点(返回 site_title/site_desc/page_size 白名单三键) | 设计 §8.1 首页要显示「站点标题」,但 §7.1 公共 API 无设置出口;`/api/admin/settings` 需要会话。新增白名单只读端点,**永不含 admin_password_hash**(测试守护) |
| D9 | `GET /api/notes/{slug}` 响应含 `prev`/`next`(相邻公开笔记 slug+title) | §8.1 详情页要求「上一篇/下一篇」;放在详情响应里避免前端额外请求与分页边界问题。排序与公开列表一致(updated_at DESC, id DESC) |
@@ -50,7 +50,7 @@
| # | 决策 | 依据 |
| --- | --- | --- |
| D27 | §13 各测试组全部落为 Go 集成测试(httptest + 临时目录真实 SQLite):可见性矩阵(主体 × 状态 × 出口表驱动)、迁移守卫、登录/改密/会话轮换、CSRF、上传、回收站+gc、slug、设置白名单;webui 用 fstest.MapFS 单测 meta 转义/缓存头/fallback | 设计「其他测试组」要求 httptest + 临时目录真实 SQLite |
| D28 | 构建冒烟 = `scripts/smoke.sh`(`make smoke`):真实构建 → init → serve → 断言 SPA script/link 200 + 正确 MIME、meta 注入、安全头、私有不可见 | §8.3-6「真实构建 → 启动二进制 → 请求任一公开 slug 页面」;比 Go test 内嵌前端产物更贴近 CI 语义 |
| D28 | 构建冒烟 = `scripts/smoke.sh`(`make smoke`):真实构建 → init → start → 断言 SPA script/link 200 + 正确 MIME、meta 注入、安全头、私有不可见 | §8.3-6「真实构建 → 启动二进制 → 请求任一公开 slug 页面」;比 Go test 内嵌前端产物更贴近 CI 语义 |
| D29 | 测试注入限流器:`httpapi.NewWithLimiters` 允许测试替换高容量桶;登录限流测试单独用真实参数构造器 | 避免全局限流 429 干扰矩阵测试,同时保留限流本身的专项测试(两全) |
| D30 | 夹具图片用「合法 PNG + IEND 后差异化尾部」绕开 sha256 去重合并——去重本身另用同字节上传断言 | 实测发现同字节图片被去重合并为同一行(正确行为,§14 已预告「去重会合并」),矩阵需要四张不同图 |
@@ -63,3 +63,4 @@
| D33 | webui 资产缓存:`assets/`(内容 hash 命名)immutable 一年;其他静态文件 1h;index.html no-cache;缺失资产 404 不回退 HTML | §8.3-5 + 防止把 JS 404 伪装成 SPA 页面造成误判 |
| D34 | 无内嵌 index.html(M0 占位)时 HTML 路径返回 503 占位说明页 | 部署缺产物属于配置错误,503 比 200 空页更诚实 |
| D35 | `robots.txt` 额外 `Disallow: /admin` | §7.1 仅要求「允许全部 + sitemap 指向」;Disallow /admin 是 SEO 常规增强,不构成功能偏离 |
| D36 | 第 2 轮评审(review-round2.md)修复随附的实现决策:维护命令经 `store.OpenData` 打开(落实 D6,`--allow-newer` 相应从维护子命令移除);Origin 校验增加 scheme 比对(可信反代后采信 X-Forwarded-Proto);邻接查询纳入 pinned 排序键;上传解码前以 `DecodeConfig` 限制像素 ≤2²⁵;备份经 umask 收紧使创建即 0600;前端写操作后统一失效公共查询缓存 | review-round2.md P1-1/P1-5/P2-11/P2-12/P2-7/P2-15 |
+3 -3
View File
@@ -564,7 +564,7 @@ example.com {
}
```
> 幂等启动:`serve` 时若 settings 无口令哈希则拒绝启动并提示先跑 `init`;`user_version` 越界同样拒绝(§10.4)。
> 幂等启动:`start` 时若 settings 无口令哈希则拒绝启动并提示先跑 `init`;`user_version` 越界同样拒绝(§10.4)。
### 10.3 备份与恢复
@@ -587,7 +587,7 @@ timer 触发 `pn-maint.service`:先 `pn gc --commit`,后 `pn backup /backup/
**要点**
- 备份进程独立于服务进程运行,不受 unit 沙箱约束;`VACUUM INTO` 在 WAL 下与 serve 并发安全(在线一致快照);
- 备份进程独立于服务进程运行,不受 unit 沙箱约束;`VACUUM INTO` 在 WAL 下与 start 并发安全(在线一致快照);
- `/backup` 目录属主 `purenote`、权限 0700;备份文件**chmod 0600**(内含全部私有笔记、图片、口令哈希、会话哈希的明文副本);
- 保留 30 天;**语义声明**:备份保留期内被删除的数据物理上仍存在于备份中;
- 异地同步(可选):`rclone` 远端启用 **crypt**(或先 `age` 加密再上传),禁止明文上云;
@@ -625,7 +625,7 @@ timer 触发 `pn-maint.service`:先 `pn gc --commit`,后 `pn backup /backup/
| 里程碑 | 内容 | 测试/验收门 |
| --- | --- | --- |
| M0 骨架 | Go 模块 + SQLite 迁移(user_version 守卫)+ 配置子命令 + 中间件链 + embed 构链(拷贝 + 占位 dist)+ `make build`/`make dev` | `make build` 产出可运行二进制;`serve` 起服务;`/api/health` 200 |
| M0 骨架 | Go 模块 + SQLite 迁移(user_version 守卫)+ 配置子命令 + 中间件链 + embed 构链(拷贝 + 占位 dist)+ `make build`/`make dev` | `make build` 产出可运行二进制;`start` 起服务;`/api/health` 200 |
| M1 公开浏览 | 笔记/标签 API + React 博客三页 + Markdown 渲染管线 + RSS/sitemap + 元信息注入(可见性规则) | 可见性矩阵**公开侧**用例起步;**构建冒烟测试**入 CI |
| M2 管理端 | 登录/会话/CSRF(`/api/me` 重取)+ 笔记 CRUD + 回收站 + 图片上传(魔数/缓存头分流)+ 编辑器 + 改密 | **表驱动可见性矩阵完整**(§13)+ 限流/CSRF/魔数/回收站用例**随功能同 PR 交付** |
| M3 安全加固 | 全量 §9.3 检查单、CSP 确认(记录 nonce 硬化待办)、CI 齐备(vet+vulncheck+测试+冒烟) | §9.3 全项通过 |
+128
View File
@@ -0,0 +1,128 @@
# 《Pure Note v1.1 实现》审评报告(第 2 轮)
> 审评对象:docs/design.md v1.1 的完整实现(Go 后端 + React 前端 + 构建/部署链),含二进制/子命令改名(pure-note → pn)后的全量代码
> 审评日期:2026-09-08
> 审评方法:三路并行代码评审(store/数据层、httpapi/中间件、前端),关键发现全部人工复核源码与文档(decisions.md D6 等)确认后成稿
> 基线:docs/review-round1.md(v1.0 设计评审)——其 P0/P1 修复要求已在 v1.1 设计中落地,本轮核对实现是否兑现
---
## 一、总体结论
**无 P0。** 默认配置下未发现远程可利用的越权或注入漏洞:SQL 全部参数化、可见性矩阵(列表/详情/标签/RSS/sitemap/图片/meta 注入)落实到位、CSRF 双保险(token + Origin/Referer 严格模式,覆盖 login/logout)、`__Host-` Cookie、PHC 口令哈希与会话摘要、meta 注入经 html/template 转义——round1 的 4 个 P0 均已在实现层封死。
但存在 **5 个 P1 功能缺陷**:其中 1 个导致前端功能完全不可用却被测试漏过(图片上传)、1 个邻接导航查询逻辑写反、1 个文档承诺(D6)与实现矛盾(升级/回滚场景备份不可用)。另有 10 项 P2 加固项。
| 级别 | 定义 | 数量 |
| --- | --- | --- |
| P0 | 远程可利用的越权/注入/私密内容泄露 | **0** |
| P1 | 功能缺陷/契约破坏/文档承诺与实现矛盾 | **5** |
| P2 | 加固与健壮性 | **10**(表内合并条目) |
---
## 二、发现汇总
| # | 级别 | 位置 | 问题一句话 |
| --- | --- | --- | --- |
| 1 | P1 | internal/store/notes.go:188-210 | 上一篇/下一篇邻接查询 tie-break 写反且忽略 pinned,同秒更新漏链/错链,无测试覆盖 |
| 2 | P1 | web/src/lib/api.ts:39-41 | api() 对 FormData 强制 `Content-Type: application/json`,前端图片上传完全不可用 |
| 3 | P1 | internal/httpapi/server.go:102 | `/api/admin/*` 与 `/api/me` 缺 `Cache-Control: no-store`(中间件注释宣称已覆盖) |
| 4 | P1 | web/src/pages/AdminEdit.tsx:113 | 自动保存成功后 invalidate 连详情查询一起失效,refetch 回滚正在输入的内容并抑制下一轮自动保存 |
| 5 | P1 | cmd/pn/main.go:241/316/340 | 维护子命令总是迁移且带守卫、`--allow-newer` 被解析但忽略,与 D6 矛盾;回滚场景连备份都无法执行 |
| 6 | P2 | store/maint.go:36-98 | GC SELECT 与 DELETE 之间竞态:恢复/新引用的活数据会被物理删除 |
| 7 | P2 | store/maint.go:109-119 | 备份先以默认 umask 创建后 chmod 0600,私有内容短暂暴露窗口 |
| 8 | P2 | auth/auth.go:57-68 | VerifyPassword 不校验 PHC 参数上下界,畸形哈希可 panic/OOM |
| 9 | P2 | httpapi/public.go:161-174 | 图片数据行竞态缺失时 404 携带 public immutable 一年 |
| 10 | P2 | httpapi/feed.go:52-75 | 无公开笔记时 lastBuildDate 输出公元 1 年(非法 RSS 日期) |
| 11 | P2 | middleware/ratelimit.go:41 | sameOrigin 只比 host 不比 scheme |
| 12 | P2 | httpapi/admin.go:344-349 | image.Decode 无尺寸上限,<5MB 解压炸弹可 OOM 整站 |
| 13 | P2 | store/settings.go:64-67 | Sscanf("%d") 部分解析("10abc"→10),脏 page_size 被静默采用 |
| 14 | P2 | docs(design:567/590/628、acceptance:53、decisions D6/D28) | 改名后残留 5 处 `serve` 旧词;D6 内容与实现矛盾(见 P1-5) |
| 15 | P2 | web/src 多处 | Home 硬编码 page_size 使站点设置失效;auth.tsx 登录竞态覆盖 CSRF;401 无全局跳转;公共缓存失效缺失等 |
---
## 三、P1 详细说明
### P1-1 上一篇/下一篇邻接查询 tie-break 写反(store/notes.go:188-210)
**问题**:公开列表序为 `ORDER BY pinned DESC, updated_at DESC, id DESC`(notes.go:169)。邻接语义应在 (updated_at, id) 字典序上取紧邻项,但:
- prev(列表中更早一篇)写 `(updated_at > ? OR (updated_at = ? AND id < ?)) ORDER BY updated_at ASC, id DESC`——`id` 比较方向与列表序(id DESC)相反;同秒组内 `id DESC` 取到的是组内顶端而非紧邻项;
- next 同理(`id > ?` + `id ASC`)。
**后果**:同一秒有两篇以上笔记更新/创建(个人站点连续保存极常见,updated_at 为秒级粒度)时 prev/next 漏链或错链;且查询完全不参与 pinned 排序,置顶笔记附近跳错条目。调用方 public.go:103-104 用 `_` 吞掉错误,静默失效。该函数**无任何测试覆盖**(已确认)。
**修复**:prev 改为 `(updated_at > ? OR (updated_at = ? AND id > ?)) ORDER BY updated_at ASC, id ASC LIMIT 1`;next 改为 `(updated_at < ? OR (updated_at = ? AND id < ?)) ORDER BY updated_at DESC, id DESC LIMIT 1`;补表驱动测试(含同秒多篇、置顶参与排序的语义确认,必要时把 pinned 纳入邻接定义)。
### P1-2 前端图片上传完全不可用(web/src/lib/api.ts:39-41)
**问题**:`api()` 对所有带 body 的请求无条件补 `Content-Type: application/json`。Editor.tsx:22 的 `uploadImage` 用 FormData(注释明确「让浏览器自动设置 Content-Type(含 boundary)」),被此逻辑覆盖为 application/json → 后端 `ParseMultipartForm` 失败 → 400,粘贴/拖拽上传功能不可用。
**为什么测试全绿**:Go 集成测试直连 API 手工构造正确 multipart 头;验收浏览器走查未覆盖上传动作;前端无该路径的测试。
**修复**:`if (options.body && !(options.body instanceof FormData) && !headers.has('Content-Type'))`;补一个前端测试或至少把上传动作纳入浏览器走查清单。
### P1-3 /api/admin/* 与 /api/me 缺 no-store(server.go:102、public.go:21-28)
**问题**:middleware.go:129-136 注释宣称 NoStore「为 /api/admin/* 与 /api/auth/* 响应统一附加」,实际接线仅 `/api/auth/`(server.go:86)挂了 NoStore,`mux.Handle("/api/admin/", s.requireAdmin(adminMux))`(server.go:102)没有。`/api/me`(已认证时返回 csrf_token)同样无。
**后果**:登出后经 bfcache/历史回退(共用设备场景)可回看管理数据(笔记全文、回收站列表、settings、CSRF token);与设计 §9.2 目标直接冲突。
**修复**:`mux.Handle("/api/admin/", middleware.NoStore(s.requireAdmin(adminMux)))`;`/api/me` 已认证分支响应前补 `Cache-Control: no-store`(或对 /api/me 整路由统一 no-store)。
### P1-4 自动保存竞态回滚用户输入(AdminEdit.tsx:108-116、62-76)
**问题**:保存成功 `qc.invalidateQueries({ queryKey: ['admin'] })` 前缀匹配连当前编辑中的详情查询(key 形如 `['admin','note',id]`)一起失效 → refetch 返回服务端快照 → `useEffect([existing])` 执行 `setForm(...)` 把保存期间用户继续输入的内容回滚,并把 `dirtyRef.current = false`,抑制下一轮自动保存。每次自动保存都经历一次「输入被吞」窗口。
**修复**:onSuccess 仅 invalidate 列表键(如 `['admin','notes']`)与回收站键,不失效当前详情;或 refetch 落地时比较 `updated_at`/内容未变才允许重置 form。
### P1-5 维护子命令与 D6 矛盾:迁移与守卫始终执行、--allow-newer 被忽略(cmd/pn/main.go:241/316/340)
**问题**:decisions.md D6 承诺「backup/gc 不执行迁移、不做 user_version 守卫(纯数据操作)」,但实现中 passwd/backup/gc 一律 `store.Open(cfg.DBPath(), false)`,而 `store.Open` 无条件 `migrate()`(store.go:105)且 `false` 关闭 `--allow-newer`。`config.ParseMaint` 解析了 `--allow-newer` 却从未使用。
**后果**:
1. 用户升级到更高 schema 后回滚旧二进制,**`pn backup` 直接被守卫拒绝**——与「升级 SOP 第一步先备份」(review-round1 P1-13 修复)的语义冲突;
2. 错误 `--data-dir` 会静默创建并迁移出一个新库(维护命令不再是无副作用的纯数据操作);
3. 传入 `--allow-newer` 无任何效果且无提示。
**修复**:按 D6 拆分「打开 + 迁移(serve/init)」与「仅数据操作打开(passwd/backup/gc,跳过迁移与守卫)」,或至少让三个子命令真正透传 `cfg.AllowNewer`;同步修订 D6/代码注释,消除文档与实现二选一的漂移。
---
## 四、P2 简述
| # | 位置 | 修复建议 |
| --- | --- | --- |
| 6 | store/maint.go:36-98 | GC commit 阶段 DELETE 带原条件复查:笔记 `WHERE id=? AND deleted_at < ?`,图片 `DELETE ... WHERE id=? AND NOT EXISTS(SELECT 1 FROM image_refs WHERE image_id=?)`(或整体单事务)——防 SELECT 与 DELETE 之间恢复/新引用导致活数据被物理删除 |
| 7 | store/maint.go:109-119 | 备份文件以 0600 创建(先 `os.OpenFile(O_CREATE|O_EXCL, 0600)` 占位或临时目录生成后 rename),消除「先以 umask 创建后 chmod」的暴露窗口与存在性竞态 |
| 8 | auth/auth.go:57-68 | 解析 PHC 后校验 `1 ≤ t ≤ 10`、`1 ≤ p ≤ 8`、`m ≤ 1<<20`(KiB)、`len(want) == 32`,不合法直接返回 false——防 t/p=0 触发 argon2 panic、m 巨大 OOM(库被篡改场景) |
| 9 | httpapi/public.go:161-174 | 缓存头移到 `GetImageData` 成功之后设置;数据行缺失的 404 不带 public immutable(防恢复后仍被缓存 404 一年) |
| 10 | httpapi/feed.go:52-75 | 无公开笔记时 lastBuildDate 用当前时间或省略字段,不输出 `Mon, 01 Jan 0001` |
| 11 | middleware/ratelimit.go:41 | `sameOrigin` 同时比较 scheme(或至少把 http/https 视为不同源);与 HSTS 形成双层防护 |
| 12 | httpapi/admin.go:344-349 | 解码校验前先 `image.DecodeConfig` 校验宽高/像素总数上限(如 ≤8192×8192 或总像素 ≤2^26),防 <5MB 高压缩图解压炸弹 OOM |
| 13 | store/settings.go:64-67 | `Sscanf("%d")` 后校验「串已完整消费」(如 `%d%c` 探尾或 strconv.Atoi 全文),拒 "10abc" 类脏值 |
| 14 | docs 多处 | 改名收尾:design.md:567/590/628、acceptance.md:53 的 `serve` → `start`;decisions.md D6/D28 同步(D28 的 `serve` 改 `start`;D6 待 P1-5 修复后按新行为重写) |
| 15 | web/src 多处 | Home.tsx:10 改用 `/api/site` 的 page_size;auth.tsx:26-45 给 refresh 加请求序号/登录态变化判定,丢弃迟到匿名响应(StrictMode 双挂载放大窗口);api.ts 401 回调接入 AuthContext 触发跳转;写操作成功后同步 invalidate `['notes']`/`['tags']`/`['note']` 公共键;TagPage 补 error 分支;上传路径加前端测试 |
---
## 五、核实通过(维持不动)
1. **可见性单一可信点**:列表/标签/RSS/sitemap 统一走 `PublicNoteFilter`(store/notes.go:152),详情/图片/meta 出口 status + deleted_at 双层裁决,私有与不存在统一 404 防枚举,id 越界/负数均 404;
2. **SQL 注入面**:全部 `?` 参数化;仅 `VACUUM INTO` 路径经 `escapeSQLString`(引号加倍,SQLite 语义正确)与内部常量 PRAGMA 两处拼接,均无注入面;
3. **认证与 CSRF**:`__Host-` + Secure + HttpOnly + Lax、登录重建会话防固定、Origin/Referer 严格模式(缺头即拒)覆盖 login/logout、token 仅内存、改密校验旧口令且会话保持;
4. **口令与令牌**:Argon2id PHC 串(参数随哈希走)、会话库存 SHA-256 摘要、常量时间比较;
5. **上传防线**:声明类型白名单 → 魔数(SVG 拒绝)→ PNG/JPEG/GIF 再解码 → ≤5MB 哨兵 → sha256 去重;
6. **输出转义**:meta 注入经 html/template、JSON 默认 HTML 转义、RSS 经 encoding/xml + goldmark(非 unsafe) + bluemonday 双层清洗;
7. **迁移与并发**:user_version 上界守卫、仅追加式、DDL 与版本号同事务幂等;WAL + busy_timeout(5000) + synchronous(NORMAL) + foreign_keys(1) + `SetMaxOpenConns(1)`;
8. **构建/部署链**:Vite `base:'/'` + sync-assets + 指纹缓存 + 冒烟脚本(19 项断言)全链路打通;
9. **重命名**:pure-note → pn 的代码/脚本/deploy/README 残留已清理干净,本轮仅剩 docs 中 5 处 `serve` 旧词(P2-14);
10. **测试资产**:可见性矩阵、CSRF、限流、上传、回收站/gc、slug、设置白名单、meta 转义、迁移守卫均有自动化用例;`go vet`/`go test`/vitest/`make smoke` 全绿。
---
## 六、结论
v1.1 实现的骨架与安全设计经受住了第二轮对抗性评审,round1 的全部 P0 修复要求已在实现层兑现,无新增 P0。需优先处理的是 5 个 P1:其中 P1-1(邻接导航)与 P1-2(图片上传)是用户可直接感知的功能性缺陷,P1-3(no-store)与 P1-5(维护命令契约)是安全/运维承诺的缺口,P1-4(自动保存竞态)影响核心编辑体验。P1 修复后建议随本轮把 P2 清单中低成本项(8/9/10/13/14)一并落地,并补上 P1-1 的邻接查询测试。
+14 -2
View File
@@ -40,7 +40,15 @@ func HashPassword(password string) (string, error) {
), nil
}
// VerifyPassword 按 PHC 串内参数重派生并常量时间比较。任何解析失败均返回 false。
// PHC 参数上下界(防畸形哈希触发 argon2 panic 或 OOM,评审 round2 P2-8):
// t/p 过小使 argon2.IDKey panic,m 过大直接耗尽内存。
const (
maxArgonTime = 10
maxArgonThreads = 8
maxArgonMemory = 1 << 20 // KiB(1 GiB),远大于现行 19456
)
// VerifyPassword 按 PHC 串内参数重派生并常量时间比较。任何解析失败或参数越界均返回 false。
func VerifyPassword(encoded, password string) bool {
parts := strings.Split(encoded, "$")
// ["", "argon2id", "v=19", "m=..,t=..,p=..", salt, hash]
@@ -57,12 +65,16 @@ func VerifyPassword(encoded, password string) bool {
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
return false
}
if t < 1 || t > maxArgonTime || p < 1 || p > maxArgonThreads ||
m < 8 || m > maxArgonMemory {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
if err != nil || len(want) != KeyLen {
return false
}
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
+20
View File
@@ -31,6 +31,26 @@ func TestPasswordHashRoundtrip(t *testing.T) {
}
}
// TestVerifyPasswordMalformedParams 畸形 PHC 参数安全返回 false:
// t/p=0 会使 argon2.IDKey panic、m 巨大会 OOM(评审 round2 P2-8)。
func TestVerifyPasswordMalformedParams(t *testing.T) {
salt := "AAAAAAAAAAAAAAAAAAAAAA" // 16 字节 base64(raw std)
hash := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" // 32 字节
bad := []string{
"$argon2id$v=19$m=19456,t=0,p=1$" + salt + "$" + hash,
"$argon2id$v=19$m=19456,t=2,p=0$" + salt + "$" + hash,
"$argon2id$v=19$m=999999999,t=2,p=1$" + salt + "$" + hash,
"$argon2id$v=19$m=19456,t=999,p=1$" + salt + "$" + hash,
"$argon2id$v=19$m=19456,t=2,p=99$" + salt + "$" + hash,
"$argon2id$v=19$m=19456,t=2,p=1$" + salt + "$AAAA", // 哈希长度异常
}
for _, h := range bad {
if VerifyPassword(h, "x") {
t.Errorf("畸形参数不应通过: %s", h)
}
}
}
func TestTokenAndStrength(t *testing.T) {
tok, err := NewToken()
if err != nil {
+4 -2
View File
@@ -24,7 +24,6 @@ func (c *Config) DBPath() string { return c.DataDir + "/pn.db" }
func addCommonFlags(fs *flag.FlagSet, c *Config) {
fs.StringVar(&c.DataDir, "data-dir", "./data", "数据目录(SQLite 数据库所在)")
fs.BoolVar(&c.AllowNewer, "allow-newer", false, "允许在更新的数据库 schema 版本上运行(跳过版本上界守卫)")
}
func addServeFlags(fs *flag.FlagSet, c *Config) {
@@ -34,6 +33,7 @@ func addServeFlags(fs *flag.FlagSet, c *Config) {
fs.StringVar(&c.LogFormat, "log-format", "text", "日志格式(text|json)")
fs.BoolVar(&c.BehindProxy, "behind-proxy", false, "位于可信反向代理之后(取 X-Forwarded-For 最右条目作为客户端 IP)")
fs.BoolVar(&c.Dev, "dev", false, "开发模式:允许非 Secure Cookie,仅允许监听 loopback 地址")
fs.BoolVar(&c.AllowNewer, "allow-newer", false, "允许在更新的数据库 schema 版本上运行(跳过版本上界守卫)")
}
// ParseStart 解析 `pn start` 参数。
@@ -55,13 +55,15 @@ func ParseInit(args []string) (*Config, error) {
c := &Config{}
fs := flag.NewFlagSet("init", flag.ContinueOnError)
addCommonFlags(fs, c)
fs.BoolVar(&c.AllowNewer, "allow-newer", false, "允许在更新的数据库 schema 版本上运行(跳过版本上界守卫)")
if err := fs.Parse(args); err != nil {
return nil, err
}
return c, nil
}
// ParseMaint 解析 `pn backup` / `pn gc` / `pn passwd` 参数(均只需数据目录)。
// ParseMaint 解析 `pn backup` / `pn gc` / `pn passwd` 参数(仅数据目录;
// 维护命令经 store.OpenData 打开,不迁移、无版本守卫,故无 --allow-newer,D6)。
// 返回 flag 解析后的剩余位置参数(如 backup 的输出路径)。
func ParseMaint(cmd string, args []string) (*Config, []string, error) {
c := &Config{}
+16 -1
View File
@@ -274,6 +274,10 @@ func (s *Server) handleAdminTrashRestore(w http.ResponseWriter, r *http.Request)
// ---- 图片上传(§7.4)----
// maxImagePixels 解码像素总数上限(1<<25 ≈ 8K 分辨率 7680×4320 ≈ 3.3×10⁷),
// 防高压缩比小体积图片解码后内存放大(解压炸弹)。
const maxImagePixels = 1 << 25
var allowedUploadTypes = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
@@ -340,8 +344,19 @@ func (s *Server) handleAdminImageUpload(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "文件内容不是受支持的图片(魔数校验失败,SVG 一律拒绝)")
return
}
// 解码校验(PNG/JPEG/GIF;WebP 由魔数保证)——拦截截断/伪造的图片流
// 解码校验(PNG/JPEG/GIF;WebP 由魔数保证)——拦截截断/伪造的图片流。
// 先 DecodeConfig 限制像素总数:防 ≤5MB 高压缩比图片解码后撑爆内存
// (解压炸弹 OOM,评审 round2 P2-12)。
if magicMime != "image/webp" {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "图片解码失败")
return
}
if cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > maxImagePixels {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "图片像素总数超过上限")
return
}
if _, _, err := image.Decode(bytes.NewReader(data)); err != nil {
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "图片解码失败")
return
+6 -1
View File
@@ -65,6 +65,11 @@ func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
Description: html,
})
}
// 无公开笔记时省略 lastBuildDate(零值 Format 会产出公元 1 年的非法日期)
lastBuildStr := ""
if !lastBuild.IsZero() {
lastBuildStr = lastBuild.Format(time.RFC1123Z)
}
feed := rssFeed{
Version: "2.0",
Channel: rssChannel{
@@ -72,7 +77,7 @@ func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
Link: base + "/",
Description: ss.SiteDesc,
Language: "zh-CN",
LastBuild: lastBuild.Format(time.RFC1123Z),
LastBuild: lastBuildStr,
Items: items,
},
}
+32
View File
@@ -0,0 +1,32 @@
package httpapi
import "testing"
// TestNoStoreHeaders 认证与管理端点响应禁缓存(§9.2,评审 round2 P1-3):
// 防登出后 bfcache/历史回退回看管理数据与 CSRF token。
func TestNoStoreHeaders(t *testing.T) {
e := newEnv(t)
// 匿名 /api/me(响应随会话态变化)
resp, _ := e.get(e.client(), "/api/me")
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
t.Errorf("匿名 /api/me 期望 Cache-Control: no-store,实际 %q", cc)
}
// 登录后 /api/me(含 csrf_token)与 /api/admin/notes
c := e.loginAdmin()
resp, _ = e.get(c, "/api/me")
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
t.Errorf("已认证 /api/me 期望 Cache-Control: no-store,实际 %q", cc)
}
resp, _ = e.get(c, "/api/admin/notes")
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
t.Errorf("/api/admin/notes 期望 Cache-Control: no-store,实际 %q", cc)
}
// /api/auth/* 维持 no-store
resp, _ = e.do(c, "POST", "/api/auth/logout", nil, nil)
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
t.Errorf("/api/auth/logout 期望 Cache-Control: no-store,实际 %q", cc)
}
}
+26 -10
View File
@@ -18,7 +18,9 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
}
// handleMe GET /api/me:匿名 {authenticated:false};已认证 {authenticated:true, csrf_token}。
// 响应随会话态变化且含 CSRF token → no-store。
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if sess, ok := s.sessionFrom(r); ok {
s.maybeRotate(w, sess)
writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "csrf_token": sess.CSRFToken})
@@ -100,8 +102,18 @@ func (s *Server) handlePublicNote(w http.ResponseWriter, r *http.Request) {
return
}
}
prevSlug, prevTitle, _ := s.st.AdjacentPublicNote(note, "prev")
nextSlug, nextTitle, _ := s.st.AdjacentPublicNote(note, "next")
prevSlug, prevTitle, err := s.st.AdjacentPublicNote(note, "prev")
if err != nil {
s.log.Error("查询上一篇失败", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
nextSlug, nextTitle, err := s.st.AdjacentPublicNote(note, "next")
if err != nil {
s.log.Error("查询下一篇失败", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": note.ID, "slug": note.Slug, "title": note.Title,
"summary": note.Summary, "content": note.Content,
@@ -133,7 +145,8 @@ func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
}
// handleImage GET /api/images/{id}:并集可见性;未授权与不存在统一 404;
// 缓存头按可见性分流(§7.4)。
// 缓存头按可见性分流(§7.4)。缓存头在数据读取成功后才设置,
// 错误路径不携带公开缓存指令(防 404 被 CDN 缓存一年)。
func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
@@ -156,10 +169,18 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
return
}
}
// 先取数据:数据行竞态缺失时返回的 404 不携带任何缓存指令
data, err := s.st.GetImageData(id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
return
}
if public {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
// 非公开图:每次请求重新判定,禁止缓存
w.Header().Set("Cache-Control", "private, no-store")
} else {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
w.Header().Set("Content-Type", img.MIME)
w.Header().Set("X-Content-Type-Options", "nosniff")
@@ -168,11 +189,6 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotModified)
return
}
data, err := s.st.GetImageData(id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
return
}
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
_, _ = w.Write(data)
}
+2 -1
View File
@@ -99,7 +99,8 @@ func (s *Server) Handler(ui http.Handler) http.Handler {
adminMux.HandleFunc("GET /api/admin/settings", s.handleAdminSettingsGet)
adminMux.Handle("PUT /api/admin/settings", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleAdminSettingsPut)))
adminMux.Handle("POST /api/admin/password", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleAdminPassword)))
mux.Handle("/api/admin/", s.requireAdmin(adminMux))
// NoStore:管理数据(笔记全文/回收站/settings)禁缓存,防登出后 bfcache 回看(§9.2)
mux.Handle("/api/admin/", middleware.NoStore(s.requireAdmin(adminMux)))
// ---- SPA(兜底,须最后注册)----
if ui != nil {
+71
View File
@@ -0,0 +1,71 @@
package httpapi
import (
"bytes"
"encoding/binary"
"hash/crc32"
"image"
"net/http"
"testing"
)
// pngWithDims 构造仅含文件签名 + IHDR 的 PNG 头(DecodeConfig 只解析头部即可
// 得到宽高,无需真实像素数据;CRC 按 PNG 规范计算)。
func pngWithDims(w, h uint32) []byte {
ihdr := make([]byte, 13)
binary.BigEndian.PutUint32(ihdr[0:4], w)
binary.BigEndian.PutUint32(ihdr[4:8], h)
// 8bit / truecolor / deflate / adaptive / no interlace
ihdr[8], ihdr[9], ihdr[10], ihdr[11], ihdr[12] = 8, 2, 0, 0, 0
chunk := bytes.NewBuffer(nil)
_ = binary.Write(chunk, binary.BigEndian, uint32(len(ihdr)))
chunk.WriteString("IHDR")
chunk.Write(ihdr)
_ = binary.Write(chunk, binary.BigEndian, crc32.ChecksumIEEE(chunk.Bytes()[4:]))
out := bytes.NewBuffer(nil)
out.Write([]byte{0x89, 'P', 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})
out.Write(chunk.Bytes())
return out.Bytes()
}
// TestUploadPixelBomb 解压炸弹防御(评审 round2 P2-12):小体积超大尺寸图片
// 在 DecodeConfig 阶段被 413 拒绝,不进入全量 Decode。
func TestUploadPixelBomb(t *testing.T) {
// 头部自证合法:DecodeConfig 可解析出宽高
bomb := pngWithDims(20000, 20000) // 4 亿像素 > 1<<25
if _, _, err := image.DecodeConfig(bytes.NewReader(bomb)); err != nil {
t.Fatalf("夹具应可解析出尺寸: %v", err)
}
// 正常小图不受影响
if _, _, err := image.DecodeConfig(bytes.NewReader(png1x1)); err != nil {
t.Fatalf("1x1 夹具应合法: %v", err)
}
e := newEnv(t)
c := e.loginAdmin()
// 手工 multipart(uploadPNG 辅助对非 201 会 Fatal)
var body bytes.Buffer
boundary := "bombboundary456"
body.WriteString("--" + boundary + "\r\n")
body.WriteString(`Content-Disposition: form-data; name="file"; filename="bomb.png"` + "\r\n")
body.WriteString("Content-Type: image/png\r\n\r\n")
body.Write(bomb)
body.WriteString("\r\n--" + boundary + "--\r\n")
req, err := http.NewRequest(http.MethodPost, e.ts.URL+"/api/admin/images", &body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
req.Header.Set("Origin", e.ts.URL)
req.Header.Set("X-CSRF-Token", e.csrf)
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("超大像素图片应 413,实际 %d", resp.StatusCode)
}
}
+50
View File
@@ -0,0 +1,50 @@
package middleware
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"
)
// TestSameOriginSchemeSameOrigin 校验 host 与 scheme 双比对(评审 round2 P2-11):
// 仅 host 相同、scheme 不同的 Origin(如对 https 站点的 http://host)必须拒绝。
func TestSameOriginScheme(t *testing.T) {
mk := func(origin string, tlsConn bool, behindProxy bool, xfp string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "http://example.com/api/auth/login", nil)
if origin != "" {
r.Header.Set("Origin", origin)
}
if tlsConn {
r.TLS = &tls.ConnectionState{}
}
if behindProxy {
r = WithBehindProxy(r, true)
}
if xfp != "" {
r.Header.Set("X-Forwarded-Proto", xfp)
}
return r
}
cases := []struct {
name string
r *http.Request
want bool
}{
{"http 直连 + http Origin", mk("http://example.com", false, false, ""), true},
{"http 直连 + https Origin(scheme 不匹配)", mk("https://example.com", false, false, ""), false},
{"https 直连 + https Origin", mk("https://example.com", true, false, ""), true},
{"https 直连 + http Origin(scheme 不匹配)", mk("http://example.com", true, false, ""), false},
{"反代 XFP=https + https Origin", mk("https://example.com", false, true, "https"), true},
{"反代 XFP=https + http Origin(scheme 不匹配)", mk("http://example.com", false, true, "https"), false},
{"反代未带 XFP 视为 http + http Origin", mk("http://example.com", false, true, ""), true},
{"host 不同", mk("http://evil.com", false, false, ""), false},
{"缺 Origin/Referer", mk("", false, false, ""), false},
}
for _, tc := range cases {
if got := sameOrigin(tc.r); got != tc.want {
t.Errorf("%s: 期望 %v,实际 %v", tc.name, tc.want, got)
}
}
}
+10 -3
View File
@@ -25,7 +25,8 @@ func OriginCheck(next http.Handler) http.Handler {
})
}
// sameOrigin 校验 Origin(或 Referer)的 host 与请求 Host 一致。
// sameOrigin 校验 Origin(或 Referer)与请求同源:host 一致且 scheme 与请求
// 实际 scheme 一致(TLS 直连为 https;可信反代后取 X-Forwarded-Proto,评审 round2 P2-11)。
func sameOrigin(r *http.Request) bool {
raw := r.Header.Get("Origin")
if raw == "" {
@@ -35,10 +36,16 @@ func sameOrigin(r *http.Request) bool {
return false
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
if err != nil || u.Host == "" || u.Scheme == "" {
return false
}
return strings.EqualFold(u.Host, r.Host)
scheme := "http"
if r.TLS != nil {
scheme = "https"
} else if trueBehindProxy(r) && strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
scheme = "https"
}
return strings.EqualFold(u.Scheme, scheme) && strings.EqualFold(u.Host, r.Host)
}
func writeErr(w http.ResponseWriter, status int, code, msg string) {
+25 -4
View File
@@ -3,6 +3,7 @@ package store
import (
"fmt"
"os"
"syscall"
"time"
)
@@ -86,15 +87,29 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
return rep, nil
}
// 条件复查删除(防 TOCTOU:SELECT 与 DELETE 之间管理员恢复笔记/图片被
// 重新引用时,不删活数据;评审 round2 P2-6)
for _, en := range rep.ExpiredNotes {
if err := s.DeleteNoteForever(en.ID); err != nil {
res, err := s.db.Exec(
`DELETE FROM notes WHERE id=? AND deleted_at IS NOT NULL AND deleted_at < ?`,
en.ID, noteCutoff)
if err != nil {
return nil, fmt.Errorf("物理删除笔记 %d: %w", en.ID, err)
}
if rows, _ := res.RowsAffected(); rows == 0 {
continue // 期间已被恢复,跳过
}
}
for _, img := range rep.OrphanImages {
if err := s.DeleteImage(img.ID); err != nil {
res, err := s.db.Exec(
`DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?)`,
img.ID, img.ID)
if err != nil {
return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err)
}
if rows, _ := res.RowsAffected(); rows == 0 {
continue // 期间已被引用,跳过
}
}
n, err := s.DeleteExpiredSessions(now.Unix())
if err != nil {
@@ -109,11 +124,17 @@ func (s *Store) Backup(destPath string) error {
if _, err := os.Stat(destPath); err == nil {
return fmt.Errorf("目标文件已存在: %s", destPath)
}
// 备份含全部私密内容:临时收紧 umask,确保 VACUUM INTO 创建即 0600
// (否则以默认 umask(常 0644)创建、事后 chmod 存在暴露窗口,评审 round2 P2-7)。
// backup 为 CLI 单线程进程,进程级 umask 无并发副作用。
old := syscall.Umask(0o077)
// VACUUM INTO 不接受参数绑定,路径经单引号转义(无参数化通道时的最小注入面)。
if _, err := s.db.Exec("VACUUM INTO " + escapeSQLString(destPath)); err != nil {
_, err := s.db.Exec("VACUUM INTO " + escapeSQLString(destPath))
syscall.Umask(old)
if err != nil {
return err
}
// 备份含全部私钥内容:强制 0600(§10.3)
// 双保险:显式收紧(幂等)
if err := os.Chmod(destPath, 0o600); err != nil {
return err
}
+12 -9
View File
@@ -183,23 +183,26 @@ func (s *Store) ListPublicNotes(page, pageSize int, tag string) ([]Note, int, er
return out, total, rows.Err()
}
// AdjacentPublicNote 返回公开序列中与 n 相邻的上一篇/下一篇
// (按列表序 updated_at DESC, id DESC)。仅返回 slug 与 title。
// AdjacentPublicNote 返回公开列表中与 n 相邻的上一篇/下一篇。
// 列表序为 pinned DESC, updated_at DESC, id DESC(notes.go ListPublicNotes),
// prev = 列表中紧邻 n 之前的一篇(元组 (pinned, updated_at, id) 字典序更大侧的最小项),
// next = 紧邻之后的一篇(字典序更小侧的最大项)。仅返回 slug 与 title。
func (s *Store) AdjacentPublicNote(n *Note, dir string) (slug, title string, err error) {
p := boolToInt(n.Pinned)
var q string
switch dir {
case "prev": // 列表中更早的一篇
case "prev": // 列表中位于 n 之前的一篇
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
AND (updated_at > ? OR (updated_at = ? AND id < ?))
ORDER BY updated_at ASC, id DESC LIMIT 1`
case "next": // 列表中更新的一篇
AND (pinned > ? OR (pinned = ? AND (updated_at > ? OR (updated_at = ? AND id > ?))))
ORDER BY pinned ASC, updated_at ASC, id ASC LIMIT 1`
case "next": // 列表中位于 n 之后的一篇
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
AND (updated_at < ? OR (updated_at = ? AND id > ?))
ORDER BY updated_at DESC, id ASC LIMIT 1`
AND (pinned < ? OR (pinned = ? AND (updated_at < ? OR (updated_at = ? AND id < ?))))
ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT 1`
default:
return "", "", fmt.Errorf("dir 必须为 prev|next")
}
row := s.db.QueryRow(q, n.UpdatedAt, n.UpdatedAt, n.ID)
row := s.db.QueryRow(q, p, p, n.UpdatedAt, n.UpdatedAt, n.ID)
if err := row.Scan(&slug, &title); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", "", nil
+4 -3
View File
@@ -3,7 +3,8 @@ package store
import (
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
)
// Settings 键白名单(§7.1 SettingsDTO 同源;admin_password_hash 永不进入 API 响应)。
@@ -61,8 +62,8 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) {
if v, ok, err := s.GetSetting(KeyPageSize); err != nil {
return nil, err
} else if ok {
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n >= 1 && n <= 100 {
// strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13)
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 1 && n <= 100 {
ss.PageSize = n
}
}
+29 -10
View File
@@ -80,8 +80,32 @@ type Store struct {
db *sql.DB
}
// Open 打开数据库并执行迁移。dbPath 为文件绝对/相对路径。
// Open 打开数据库并执行迁移(start/init 用)。dbPath 为文件绝对/相对路径。
// allowNewer 跳过 user_version 上界守卫(§10.4)。
func Open(dbPath string, allowNewer bool) (*Store, error) {
db, err := openDB(dbPath)
if err != nil {
return nil, err
}
if err := migrateDB(db, allowNewer); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
// OpenData 打开数据库仅做数据操作:**不迁移、不做版本守卫**
// (passwd/backup/gc 维护子命令,D6:在更新 schema 的库上备份/清理
// 不应被守卫阻断,也不应产生迁移副作用)。
func OpenData(dbPath string) (*Store, error) {
db, err := openDB(dbPath)
if err != nil {
return nil, err
}
return &Store{db: db}, nil
}
func openDB(dbPath string) (*sql.DB, error) {
// SQLite URI 不接受相对路径
abs, err := filepath.Abs(dbPath)
if err != nil {
@@ -101,24 +125,19 @@ func Open(dbPath string, allowNewer bool) (*Store, error) {
}
// 单写者串行化(§6.2)
db.SetMaxOpenConns(1)
s := &Store{db: db}
if err := s.migrate(allowNewer); err != nil {
db.Close()
return nil, err
}
return s, nil
return db, nil
}
func (s *Store) migrate(allowNewer bool) error {
func migrateDB(db *sql.DB, allowNewer bool) error {
var v int
if err := s.db.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
if err := db.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
return err
}
if v > MaxSchemaVersion && !allowNewer {
return fmt.Errorf("%w: 库版本 %d > 支持上限 %d", ErrSchemaNewer, v, MaxSchemaVersion)
}
for i := v; i < MaxSchemaVersion; i++ {
tx, err := s.db.Begin()
tx, err := db.Begin()
if err != nil {
return err
}
+92
View File
@@ -97,6 +97,98 @@ func TestForeignKeysCascade(t *testing.T) {
}
}
// TestOpenDataNoMigrate 维护命令打开方式:不迁移、不做版本守卫(D6,评审 round2 P1-5)。
func TestOpenDataNoMigrate(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "pn.db")
// 全新路径:OpenData 不创建 schema
s, err := OpenData(dbPath)
if err != nil {
t.Fatal(err)
}
v, err := s.SchemaVersion()
if err != nil {
t.Fatal(err)
}
if v != 0 {
t.Fatalf("OpenData 不应执行迁移,user_version=%d", v)
}
var one int
if err := s.db.QueryRow("SELECT 1 FROM notes LIMIT 1").Scan(&one); err == nil {
t.Fatal("OpenData 不应创建表")
}
s.Close()
// 更高 schema 版本:OpenData 不被守卫拒绝(升级/回滚场景备份仍可用)
s2, err := Open(dbPath, false)
if err != nil {
t.Fatal(err)
}
if _, err := s2.DB().Exec("PRAGMA user_version = 99"); err != nil {
t.Fatal(err)
}
s2.Close()
if _, err := Open(dbPath, false); !errors.Is(err, ErrSchemaNewer) {
t.Fatalf("Open 仍应被守卫拒绝,实际 %v", err)
}
s3, err := OpenData(dbPath)
if err != nil {
t.Fatalf("OpenData 不做版本守卫,应放行: %v", err)
}
s3.Close()
}
// TestAdjacentPublicNote 邻接查询:列表序 pinned DESC, updated_at DESC, id DESC
// 的紧邻语义(评审 round2 P1-1:同秒 tie-break 与 pinned 参与排序;仅统计公开)。
func TestAdjacentPublicNote(t *testing.T) {
s := openTestStore(t)
mk := func(slug string, at int64, pinned bool, status string) {
t.Helper()
if _, err := s.CreateNote(&Note{Slug: slug, Title: slug, Status: status,
Tags: []string{}, Pinned: pinned, CreatedAt: at, UpdatedAt: at}); err != nil {
t.Fatal(err)
}
}
mk("a", 100, false, "public") // id=1
mk("b", 100, false, "public") // id=2(与 a 同秒,列表中排在 a 之前)
mk("c", 80, false, "public") // id=3
mk("p", 50, true, "public") // id=4 置顶 → 列表首
mk("priv", 200, false, "private") // 更新的私有笔记:不得出现在邻接中
get := func(slug string) *Note {
n, err := s.GetNoteBySlug(slug)
if err != nil {
t.Fatal(err)
}
return n
}
// 公开列表序:p > b > a > c(priv 被过滤)
cases := []struct{ slug, prev, next string }{
{"p", "", "b"}, // 置顶首篇
{"b", "p", "a"},
{"a", "b", "c"}, // a 与 b 同秒:prev 应取同秒中紧邻的 b(id 更大),而非跳过
{"c", "a", ""},
}
for _, tc := range cases {
prevSlug, _, err := s.AdjacentPublicNote(get(tc.slug), "prev")
if err != nil {
t.Fatalf("%s prev: %v", tc.slug, err)
}
nextSlug, _, err := s.AdjacentPublicNote(get(tc.slug), "next")
if err != nil {
t.Fatalf("%s next: %v", tc.slug, err)
}
if prevSlug != tc.prev || nextSlug != tc.next {
t.Errorf("%s: 期望 prev=%q next=%q,实际 prev=%q next=%q",
tc.slug, tc.prev, tc.next, prevSlug, nextSlug)
}
}
if _, _, err := s.AdjacentPublicNote(get("a"), "bad"); err == nil {
t.Error("非法 dir 应返回错误")
}
}
func openTestStore(t *testing.T) *Store {
t.Helper()
s, err := Open(filepath.Join(t.TempDir(), "test.db"), false)
+12 -2
View File
@@ -25,6 +25,13 @@ export function getCsrfToken(): string | null {
return csrfToken
}
// 401 统一回调:由 AuthProvider 注册(置未登录态 → RequireAuth 跳 /admin/login)
let unauthorizedHandler: (() => void) | null = null
export function setUnauthorizedHandler(fn: (() => void) | null) {
unauthorizedHandler = fn
}
interface Envelope<T> {
data?: T
error?: { code: string; message: string; field?: string }
@@ -36,7 +43,9 @@ export async function api<T>(
): Promise<T> {
const method = (options.method ?? 'GET').toUpperCase()
const headers = new Headers(options.headers)
if (options.body && !headers.has('Content-Type')) {
// FormData 由浏览器自动生成含 boundary 的 multipart Content-Type,不可覆写
// (曾因此破坏图片上传,评审 round2 P1-2)
if (options.body && !(options.body instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
if (method !== 'GET' && method !== 'HEAD' && csrfToken) {
@@ -54,8 +63,9 @@ export async function api<T>(
if (!res.ok) {
const err = body?.error
if (res.status === 401) {
// 登录态失效:清内存态,统一由 AuthContext 跳登录页
// 登录态失效:清内存态并通知全局回调(AuthProvider 跳登录页)
setCsrfToken(null)
unauthorizedHandler?.()
}
throw new ApiError(
res.status,
+20 -3
View File
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { api, setCsrfToken } from './api'
import { api, setCsrfToken, setUnauthorizedHandler } from './api'
interface Me {
authenticated: boolean
@@ -22,10 +22,15 @@ const AuthContext = createContext<AuthState | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true)
const [authenticated, setAuthenticated] = useState(false)
// 请求序号:login/logout 递增,使竞态中的迟到 /api/me 响应被丢弃
// (防登录成功后被先前的匿名响应覆盖,评审 round2 P2-15)
const seqRef = useRef(0)
const refresh = useCallback(async () => {
const seq = ++seqRef.current
try {
const me = await api<Me>('/api/me')
if (seqRef.current !== seq) return // 状态已被 login/logout 改变,丢弃迟到响应
setAuthenticated(me.authenticated)
if (me.authenticated && me.csrf_token) {
setCsrfToken(me.csrf_token)
@@ -33,10 +38,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setCsrfToken(null)
}
} catch {
if (seqRef.current !== seq) return
setAuthenticated(false)
setCsrfToken(null)
} finally {
setLoading(false)
if (seqRef.current === seq) setLoading(false)
}
}, [])
@@ -44,12 +50,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
void refresh()
}, [refresh])
// api 层 401 统一回调:置未登录态,RequireAuth 随即跳转 /admin/login
useEffect(() => {
setUnauthorizedHandler(() => {
setAuthenticated(false)
setCsrfToken(null)
})
return () => setUnauthorizedHandler(null)
}, [])
const login = useCallback(
async (password: string) => {
const res = await api<{ csrf_token: string }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
})
seqRef.current++ // 使在途的匿名 /api/me 响应失效
setCsrfToken(res.csrf_token)
setAuthenticated(true)
},
@@ -58,6 +74,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const logout = useCallback(async () => {
await api('/api/auth/logout', { method: 'POST' })
seqRef.current++
setCsrfToken(null)
setAuthenticated(false)
}, [])
+14
View File
@@ -0,0 +1,14 @@
import type { QueryClient } from '@tanstack/react-query'
/**
* 写操作成功后失效公共数据缓存(列表/标签页/详情/标签云)。
* 公共查询 staleTime=30s 且不随窗口聚焦刷新,管理端改动若不失效,
* 返回公共页最长 30s 展示旧内容(评审 round2 P2-15)。
* 前缀匹配:['notes'] → ['notes',page,tag],['note'] → ['note',slug]。
*/
export function invalidatePublic(qc: QueryClient) {
void qc.invalidateQueries({ queryKey: ['notes'] })
void qc.invalidateQueries({ queryKey: ['tag'] })
void qc.invalidateQueries({ queryKey: ['note'] })
void qc.invalidateQueries({ queryKey: ['tags'] })
}
+5 -1
View File
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Bold, Italic, Link2, Code, Table, Eye, EyeOff } from 'lucide-react'
import { api, ApiError } from '../lib/api'
import type { NoteItem } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate'
import MarkdownViewer from '../components/MarkdownViewer'
import { slugPreview } from '../lib/utils'
@@ -110,7 +111,10 @@ export default function AdminEdit() {
setSlugFieldError(null)
setErrorMsg(null)
dirtyRef.current = false
void qc.invalidateQueries({ queryKey: ['admin'] })
// 仅失效列表与公共缓存;不失效当前编辑详情(['admin','note',id]),
// 防 refetch 回滚保存期间继续输入的内容并抑制下一轮自动保存(评审 round2 P1-4)
void qc.invalidateQueries({ queryKey: ['admin', 'notes'] })
invalidatePublic(qc)
if (isNew) {
navigate(`/admin/notes/${saved.id}/edit`, { replace: true })
}
+6 -1
View File
@@ -4,6 +4,7 @@ import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { api } from '../lib/api'
import type { NoteItem } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate'
import { useAuth } from '../lib/auth'
import { formatDateTime } from '../lib/utils'
@@ -26,6 +27,7 @@ export default function AdminList() {
onSuccess: () => {
setConfirmId(null)
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
})
@@ -46,7 +48,10 @@ export default function AdminList() {
}),
})
},
onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin'] }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
})
return (
+2
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api, ApiError } from '../lib/api'
import type { SiteSettings } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate'
export default function AdminSettings() {
const qc = useQueryClient()
@@ -32,6 +33,7 @@ export default function AdminSettings() {
onSuccess: () => {
setMsg('已保存')
void qc.invalidateQueries({ queryKey: ['site'] })
invalidatePublic(qc) // page_size 等设置影响公共列表
},
onError: (e) => setMsg(e instanceof ApiError ? e.message : '保存失败'),
})
+5 -1
View File
@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { RotateCcw } from 'lucide-react'
import { api } from '../lib/api'
import type { NoteItem } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate'
import { formatDateTime } from '../lib/utils'
export default function AdminTrash() {
@@ -13,7 +14,10 @@ export default function AdminTrash() {
const restore = useMutation({
mutationFn: (id: number) => api(`/api/admin/trash/${id}/restore`, { method: 'POST' }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin'] }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
})
return (
+2 -1
View File
@@ -7,7 +7,8 @@ import type { NoteItem, NoteList, TagCount } from '../lib/api'
import { formatDate } from '../lib/utils'
function useNotes(page: number, tag?: string) {
const qs = new URLSearchParams({ page: String(page), page_size: '10' })
// 不传 page_size:服务端默认值即站点设置(管理员可改),首页随设置生效
const qs = new URLSearchParams({ page: String(page) })
if (tag) qs.set('tag', tag)
return useQuery({
queryKey: ['notes', page, tag ?? ''],
+3 -1
View File
@@ -6,7 +6,7 @@ import { formatDate } from '../lib/utils'
export default function TagPage() {
const { tag = '' } = useParams()
const { data, isLoading } = useQuery({
const { data, isLoading, error } = useQuery({
queryKey: ['tag', tag],
queryFn: () => api<NoteList>(`/api/notes?tag=${encodeURIComponent(tag)}&page=1&page_size=100`),
})
@@ -24,6 +24,8 @@ export default function TagPage() {
</header>
{isLoading ? (
<p className="py-12 text-center text-zinc-400">加载中…</p>
) : error ? (
<p className="py-12 text-center text-red-500">加载失败,请稍后重试</p>
) : !data || data.items.length === 0 ? (
<p className="py-12 text-center text-zinc-400">该标签下暂无公开笔记</p>
) : (
+67
View File
@@ -0,0 +1,67 @@
// api 客户端单测(评审 round2 P1-2 / P2-15 的回归守护):
// 1. FormData 请求不得强设 Content-Type(multipart boundary 由浏览器生成);
// 2. 401 触发全局未授权回调(AuthProvider 据此跳登录页)。
import { describe, it, expect, vi, afterEach } from 'vitest'
import { api, ApiError, setUnauthorizedHandler } from '../src/lib/api'
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
describe('api 客户端', () => {
afterEach(() => {
vi.unstubAllGlobals()
setUnauthorizedHandler(null)
})
it('FormData 请求不强设 Content-Type(保留浏览器 multipart boundary)', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(201, { data: { id: 1, url: '/api/images/1' } }))
vi.stubGlobal('fetch', fetchMock)
const form = new FormData()
form.append('file', new Blob(['x'], { type: 'image/png' }), 'a.png')
await api<{ id: number }>('/api/admin/images', { method: 'POST', body: form })
expect(fetchMock).toHaveBeenCalledOnce()
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
const headers = init.headers as Headers
expect(headers.has('Content-Type')).toBe(false)
})
it('JSON 请求默认补 application/json 并携带 CSRF 头', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { data: { ok: true } }))
vi.stubGlobal('fetch', fetchMock)
const { setCsrfToken } = await import('../src/lib/api')
setCsrfToken('token-abc')
await api('/api/admin/notes/1', { method: 'PUT', body: JSON.stringify({ title: 'x' }) })
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
const headers = init.headers as Headers
expect(headers.get('Content-Type')).toBe('application/json')
expect(headers.get('X-CSRF-Token')).toBe('token-abc')
setCsrfToken(null)
})
it('401 触发全局未授权回调并抛出 ApiError', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse(401, { error: { code: 'unauthorized', message: '未登录或会话已过期' } }),
)
vi.stubGlobal('fetch', fetchMock)
const handler = vi.fn()
setUnauthorizedHandler(handler)
try {
await api('/api/admin/notes', { method: 'DELETE' })
expect.unreachable('应抛出 ApiError')
} catch (e) {
expect(e).toBeInstanceOf(ApiError)
expect((e as ApiError).status).toBe(401)
expect((e as ApiError).code).toBe('unauthorized')
}
expect(handler).toHaveBeenCalledOnce()
})
})