构建、部署与验收文档:Makefile、冒烟脚本、systemd/Caddy、决策与验收报告
- Makefile:web / sync-assets(go:embed 约束拷贝)/ build / linux / test / smoke / dev / vulncheck;版本信息 ldflags 注入 - scripts/smoke.sh:构建冒烟(§8.3-6)——起服务断言 SPA 资源 200 + 正确 MIME、meta 注入、安全头、私有不可见(19 项断言) - deploy:pure-note.service(沙箱加固)、每日 gc+backup 的 maint service/timer(03:00 Persistent)、Caddyfile 反代示例 - docs/decisions.md:35 条实施决策留档(D1–D35) - docs/acceptance.md:§9.3 检查单逐项证据、§13 测试组映射、 §12 里程碑门、浏览器端到端走查记录 - README.md:快速开始、测试、部署与升级/恢复 SOP
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
# 最终验收报告
|
||||
|
||||
> 依据:docs/design.md(v1.1);决策记录:docs/decisions.md
|
||||
> 日期:2026-09-08
|
||||
|
||||
## 一、验收结论
|
||||
|
||||
**全部通过。** 设计文档定义的功能(§2.1 F1–F8)、安全设计(§9)、测试策略(§13)
|
||||
与里程碑验收门(§12 M0–M4)均已实现并有自动化测试或实测证据支撑。
|
||||
`go vet` / `govulncheck` / `npm audit` 干净;全量测试绿;构建冒烟 19 项断言全过;
|
||||
真实浏览器端到端走查(博客浏览 / 登录 / 编辑器 / 回收站)通过。
|
||||
|
||||
## 二、§9.3 上线前安全检查单(逐项)
|
||||
|
||||
| 检查项 | 状态 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| go vet + govulncheck 干净;npm audit 干净 | ✅ | `go vet ./...` 通过;`govulncheck ./...`:0 个影响本代码的漏洞;`npm audit --omit=dev`:0 vulnerabilities |
|
||||
| 可见性矩阵自动化用例全绿(列表/详情/标签/RSS/sitemap/图片直链/HTML meta/管理接口) | ✅ | `TestVisibilityMatrix`(表驱动:{匿名,管理员} × {公开,私有,回收站,不存在} × 7 出口 + 图片缓存头分流 + 404 防枚举一致性) |
|
||||
| 恶意 Markdown 渲染无脚本执行 | ✅ | 前端 vitest 冒烟(script/iframe/事件属性/javascript:/style/SVG 全拦截 + schema 快照);服务端 goldmark 转义 + bluemonday(`TestRenderEscapesDangerousHTML`);CSP `script-src 'self'` 兜底(冒烟断言) |
|
||||
| 跨站伪造 POST(含 login/logout)被拒;Cookie 属性与 `__Host-` 前缀;刷新后 CSRF 经 /api/me 重取 | ✅ | `TestCSRF`(无 token/错 token/跨源 login/logout/缺 Origin+Referer);`TestLoginFlow`(/api/me 返回 csrf_token);实测 Set-Cookie:`__Host-pn_session=…; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Lax` |
|
||||
| 伪造扩展名/SVG/超限文件被拒 | ✅ | `TestUploadValidation`(魔数不符 415、SVG 拒绝、声明类型白名单、空文件 400、>5MB 413、同字节去重同 id) |
|
||||
| 登录爆破触发 429;失败日志含 IP | ✅ | `TestLoginRateLimit`(第 6 次失败 429 + Retry-After、正确口令在桶空时同样 429、成功不计费);实测日志 `login_failed ip=127.0.0.1 ip_bucket_left=9 account_bucket_left=4` |
|
||||
| settings 响应不含口令哈希;PUT 无法写非白名单键;改密通过 | ✅ | `TestSettingsWhitelist`(GET 不含 password/argon2、未知键 400、page_size 生效);`TestPasswordChange`(旧错 401/弱新 400/成功/旧口令失效/当前会话保持) |
|
||||
| 图片:私有与不存在统一 404;缓存头 immutable vs no-store | ✅ | `TestVisibilityMatrix` 图片出口(公开 immutable / 非公开 no-store / 匿名 404 响应体一致防枚举) |
|
||||
| 安全头逐项确认(含 HSTS) | ✅ | 冒烟脚本断言 HSTS/X-Frame-Options/nosniff/CSP;全套头由 SecurityHeaders 中间件统一下发(curl 实测) |
|
||||
| 备份还原演练(停服→删 wal/shm→替换 db→恢复);备份 0600 | ✅ | 演练实测:备份文件 `-rw-------`(0600);删 wal/shm → 换备份 → 起服 → 数据完整可读 |
|
||||
| 回收站恢复用例;gc dry-run→commit;7 天内孤儿图片不删 | ✅ | `TestTrashLifecycleAndGC` + CLI 实测(dry-run 报告、commit 执行、宽限期保留) |
|
||||
| user_version 越界启动被拒;--allow-newer 放行 | ✅ | `TestMigrationNewerRejected` + CLI 实测(拒启 exit=1;--allow-newer 后 /api/health 200) |
|
||||
| 构建冒烟测试通过 | ✅ | `make smoke`(npm ci → vite build → sync-assets → CGO_ENABLED=0 交叉构建 → 起服务 19 项断言:SPA 资源 200+MIME、meta 注入、安全头、私有不可见) |
|
||||
| Caddy 仅开放 80/443、程序监听 127.0.0.1 | ✅(配置交付) | deploy/Caddyfile + pure-note.service(`--addr 127.0.0.1:8080 --behind-proxy`)——本机无生产部署环境,按配置文件验收 |
|
||||
|
||||
## 三、§13 测试策略落地
|
||||
|
||||
| 测试组 | 用例 | 位置 |
|
||||
| --- | --- | --- |
|
||||
| 可见性矩阵 | 表驱动全出口(§13 矩阵全覆盖,含图片并集语义与缓存头分流) | internal/httpapi/visibility_test.go |
|
||||
| 迁移 | 空库→最新幂等;越界拒启;allow-newer 放行;外键级联 | internal/store/store_test.go |
|
||||
| 认证会话 | 登录成功/失败统一文案、429、改密三态、轮换后 CSRF 不变、/api/me 两态、登出 | internal/httpapi/api_test.go |
|
||||
| CSRF | 无/错 token 403、跨源(含 login/logout)、缺 Origin+Referer | 同上 |
|
||||
| 上传 | 魔数/伪造扩展/SVG/超限/空/去重 | 同上 |
|
||||
| 回收站 | 删→不可见→恢复→可见;图片引用保全;30 天 gc;7 天宽限 | 同上 |
|
||||
| gc | dry-run 不改数据、过期会话清理 | 同上 |
|
||||
| slug | 中文 post-YYYYMMDD、同日冲突 -2、回收站占用、手改 409 字段级错误、非法格式 400 | 同上 |
|
||||
| 元信息注入 | 公开注入笔记 meta;私有/不存在回退站点默认;html/template 转义 | visibility_test.go + webui_test.go |
|
||||
| 前端 vitest | sanitize schema 快照 + 恶意 Markdown 冒烟 + GFM 回归 + node prop 泄漏检查 | web/tests/markdown.test.tsx(6/6) |
|
||||
|
||||
**回归命令**:`make test`(go vet + go test ./... + vitest)与 `make smoke`。
|
||||
|
||||
## 四、里程碑验收门(§12)
|
||||
|
||||
| 里程碑 | 验收门 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| M0 骨架 | `make build` 产出可运行二进制;serve 起服务;/api/health 200 | ✅ |
|
||||
| M1 公开浏览 | 笔记/标签 API + 博客三页 + 渲染管线 + RSS/sitemap + meta 注入;公开侧可见性用例;构建冒烟 | ✅ |
|
||||
| M2 管理端 | 登录/会话/CSRF + CRUD + 回收站 + 图片上传 + 编辑器 + 改密;完整矩阵 + 各组用例随功能交付 | ✅ |
|
||||
| M3 安全加固 | §9.3 全项通过(见上表);CSP nonce 硬化按设计留 M3+ 待办 | ✅ |
|
||||
| M4 运维 | systemd ×3 + Caddyfile + 备份恢复演练真还原 | ✅(压测 hey 未做:单机个人规模非验收必需,工具未安装;`SetMaxOpenConns(1)` 串行模型按设计实现) |
|
||||
|
||||
## 五、真实浏览器走查(补充验收)
|
||||
|
||||
以内置浏览器对 `make build` 产物做黑盒走查,全部通过:
|
||||
|
||||
1. **博客首页**:暗色主题、站点标题、置顶排序、标签云计数、页脚 RSS/Sitemap;
|
||||
2. **详情页**:面包屑、标题/日期/标签、GFM 表格与任务列表(disabled checkbox)渲染、上一篇/下一篇;
|
||||
3. **登录**:错误口令提示、成功跳转 /admin;
|
||||
4. **管理列表**:公/私徽标、slug/时间/标签、状态切换/编辑/删除;
|
||||
5. **编辑器**:CodeMirror 输入、分屏实时预览、保存后自动生成 slug("go")、摘要自动截取、URL 迁移到 /notes/3/edit;
|
||||
6. **回收站**:删除确认弹窗 → 列表移除 → 回收站出现 → 恢复 → 公开页重新可见(标签页标题即 meta 注入验证)。
|
||||
|
||||
## 六、交付物清单
|
||||
|
||||
```
|
||||
pure-note(单二进制,CGO_ENABLED=0,go:embed 前端产物)
|
||||
├── cmd/pure-note/ serve / init / backup / gc / version
|
||||
├── internal/{config,store,auth,markdown,middleware,httpapi,webui}
|
||||
├── web/ React 19 + Vite 8 + Tailwind 4(vitest 6 用例)
|
||||
├── deploy/ pure-note.service、pure-note-maint.{service,timer}、Caddyfile
|
||||
├── scripts/smoke.sh 构建冒烟(make smoke)
|
||||
├── Makefile / README.md / .gitignore
|
||||
└── docs/{design.md, review-round1.md, decisions.md, acceptance.md}
|
||||
```
|
||||
|
||||
依赖面:Go 直接依赖 6 个(预算 ≤6);npm 生产依赖与设计一致,0 漏洞。
|
||||
锁定文件 go.sum / package-lock.json 入库。
|
||||
|
||||
## 七、遗留事项(不阻塞验收)
|
||||
|
||||
1. CSP `style-src` nonce 硬化 —— 设计明示 M3+ 待办(§9.2/§14),v1 保留 'unsafe-inline';
|
||||
2. hey 压测 —— M4 可选项,需生产规格环境;
|
||||
3. `vite dev` 开发态标签页标题显示模板占位符(D23,仅开发态外观);
|
||||
4. shadcn CLI 未使用,按「复制式组件」理念手写等价最小组件集(D22)。
|
||||
@@ -0,0 +1,65 @@
|
||||
# 实施决策记录
|
||||
|
||||
> 本文档记录按 `docs/design.md`(v1.1)实施过程中的全部自行决策与偏差说明。
|
||||
> 原则:不偏离设计主线;凡设计未明确或环境受限处,选择最小侵入方案并在此留档。
|
||||
> 日期:2026-09-08
|
||||
|
||||
## 1. 环境与依赖
|
||||
|
||||
| # | 决策 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| D1 | Go 工具链使用 **1.26.6**(设计写 1.27.1) | 本机已装版本。所用特性(1.22+ ServeMux 方法/通配符路由、slog、go:embed)全部满足,无升级必要 |
|
||||
| D2 | 依赖版本按设计锁定值解析:modernc.org/sqlite v1.58.0、goldmark v1.8.6、bluemonday v1.0.27、x/crypto v0.56.0、x/time v0.15.0 | 设计附录 A「实际开发时以 go mod tidy 解析到的版本为准」——解析结果与设计核实值完全一致 |
|
||||
| D3 | 新增第 6 个直接依赖 **golang.org/x/term v0.45.0** | `pure-note init` 交互式口令输入需要关闭回显。设计 §2.2 预算「直接依赖 ≤6(Go 侧 5 个)」,6 个在预算内;`PN_ADMIN_PASSWORD` 环境变量仍是非交互主通道 |
|
||||
| D4 | 前端 TypeScript 用 **5.9**(设计允许 7.x 或回退 5.9) | 直接采用设计声明的保守回退路径,规避第三方类型兼容风险(CodeMirror/react-markdown 生态) |
|
||||
| D5 | 前端其余依赖与设计一致:react 19.2.8、vite 8.2.2、tailwind 4.3.3、react-router 8.3.1、@tanstack/react-query 5.102.8、react-markdown 10.1.0 等,npm 解析 0 漏洞 | `npm install` 实测 |
|
||||
|
||||
## 2. 后端行为细化
|
||||
|
||||
| # | 决策 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| D6 | `backup` / `gc` 子命令**不执行迁移、不做 user_version 守卫**(只有 `serve`/`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) |
|
||||
| D10 | RSS `description` = 全文服务端渲染(goldmark→bluemonday),条目上限 50;`pubDate` 用 RFC 822(RFC1123Z) | §7.5 要求服务端渲染;全量 description 是博客 RSS 常规做法。注意从 `ListPublicNotesFull` 取(列表查询不含全文,曾因此修复空 description 缺陷) |
|
||||
| D11 | 管理列表 `GET /api/admin/notes` 不分页,一次返回全部正常笔记 | 设计未定义该端点分页参数;§11 边界 1 万篇内无压力。公开列表 `GET /api/notes` 保持分页 |
|
||||
| D12 | **Origin/Referer 校验严格模式**:非 GET/HEAD/OPTIONS 请求若 Origin 与 Referer 均缺失也拒绝(403) | 浏览器跨站/同站 fetch 都会带 Origin;缺失意味着非浏览器伪造。设计 T2「所有非 GET 请求」从严解释。测试覆盖 login/logout/admin 三处 |
|
||||
| D13 | 登录限流 fail-only 实现:**预检**(桶空 → 429+Retry-After,跳过 Argon2)+ **失败时消耗**两维度令牌;per-IP 10次/5min + per-账号 5次/10min;改密复用同一组限流器 | §7.2/§7.3。「预检不消费」保证成功登录不计费;429 时 Retry-After 取两维度较大值 |
|
||||
| D14 | 全局限流参数取 20 req/s、burst 40/桶上限 4096、TTL 10 分钟 | 设计只说「宽松令牌桶」,此参数对个人博客足够宽松 |
|
||||
| D15 | `image_refs` 重建按设计正则 `/api/images/(\d+)` 全量扫描去重;悬空引用(图片不存在)忽略 | §6.2 原文 |
|
||||
| D16 | 图片上传双重校验:客户端 Content-Type 白名单 → 魔数(PNG/JPEG/WebP/GIF,SVG 拒绝)→ PNG/JPEG/GIF 再过 stdlib 解码(拦截截断流);WebP 仅魔数(stdlib 不支持) | §7.4「不信任客户端声明」。多一层解码校验属纵深,不改变接口语义 |
|
||||
| D17 | 摘要为空时服务端自动生成:Markdown→HTML→剥标签→压空白→截 200 字符 | §6.1 summary「可空则截取正文」 |
|
||||
| D18 | slug 生成:ASCII 部分小写连字符化;**结果为空才退化为 post-YYYYMMDD**;唯一性查重**含回收站**,冲突自动 -2/-3;POST 创建忽略客户端 slug 一律自动生成;PUT 允许手改(格式校验 + 排除自身查重,冲突 409 + `field:"slug"`) | §8.2。创建路径「自动生成 + 自解冲突」,手改冲突走 409——与「首次保存即定稿」一致:新建后前端立即拿到真实 slug |
|
||||
| D19 | 回收站中的笔记对所有公开出口(含管理员经 `/api/notes/{slug}`)一律 404,仅 `/api/admin/trash` 可见 | §13 判定规则「回收站仅经 /api/admin/trash」(管理员私有预览只适用于未删除的私有笔记,§1.3) |
|
||||
| D20 | 设置 PUT 用 `DisallowUnknownFields` 严格白名单:未知键 400(含试图写 admin_password_hash) | §9.3「PUT 无法写入非白名单键」 |
|
||||
| D21 | 改密成功**不失效当前会话** | §7.3-7 明示(单管理员仅本会话) |
|
||||
|
||||
## 3. 前端
|
||||
|
||||
| # | 决策 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| D22 | **未使用 shadcn CLI / Radix 原语**:按 shadcn「复制式组件」理念手写所需的最小组件集(按钮/输入/开关/模态/布局),样式走 Tailwind 类 | 无头环境下 CLI 交互链路风险高、收益低;设计定位是「复制式组件零运行时」,手写与 CLI 产物等价且依赖面更小。设计「按需叠加 Radix 原语」——本项目需求内为零需要 |
|
||||
| D23 | index.html 内嵌 Go template 占位符 `{{.Title}}` 等;vite 原样保留,服务端渲染时经 html/template 自动转义;字段由服务端保证非空(站点默认回退) | §8.3-4。副作用:`vite dev` 直连时标签页标题显示占位符原文,纯开发态外观问题 |
|
||||
| D24 | rehype-sanitize 在 GitHub 默认 schema 上扩展:`input` 补 `['checked', true]`(默认 schema 已含 type=checkbox/disabled)、`code`/`span` 追加 `hljs*` className 白名单;`a` 组件强制 `target=_blank rel="nofollow noopener noreferrer"` | §8.2 渲染管线。schema 快照测试守护「禁 script/iframe/style/事件属性」 |
|
||||
| D25 | 新建笔记**首次手动保存**时才 POST 创建(自动保存仅对已存在笔记生效),避免半空草稿泛滥 | §8.2 自动保存防抖 2s 的安全解释;「草稿即私有」不受影响 |
|
||||
| D26 | 编辑器工具栏提供加粗/斜体/链接/代码/表格插入;图片粘贴/拖拽上传后插入 `` | §8.2 |
|
||||
|
||||
## 4. 测试与验收
|
||||
|
||||
| # | 决策 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| 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 语义 |
|
||||
| D29 | 测试注入限流器:`httpapi.NewWithLimiters` 允许测试替换高容量桶;登录限流测试单独用真实参数构造器 | 避免全局限流 429 干扰矩阵测试,同时保留限流本身的专项测试(两全) |
|
||||
| D30 | 夹具图片用「合法 PNG + IEND 后差异化尾部」绕开 sha256 去重合并——去重本身另用同字节上传断言 | 实测发现同字节图片被去重合并为同一行(正确行为,§14 已预告「去重会合并」),矩阵需要四张不同图 |
|
||||
|
||||
## 5. 其他
|
||||
|
||||
| # | 决策 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| D31 | CSP v1 采用设计原样(style-src 含 'unsafe-inline',CodeMirror style-mod 所需);nonce 硬化按设计留 M3+ 待办,未实施 | §9.2/§14 |
|
||||
| D32 | 安全头(含 HSTS)对全部响应统一下发 | §9.2 为全局中间件;HTTP 开发模式下 HSTS 无副作用 |
|
||||
| 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 常规增强,不构成功能偏离 |
|
||||
Reference in New Issue
Block a user