构建、部署与验收文档: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,53 @@
|
||||
# Pure Note 构建与运维入口
|
||||
#
|
||||
# 常用目标:
|
||||
# make web 构建前端(npm ci + vite build → web/dist)
|
||||
# make sync-assets 拷贝 web/dist → internal/webui/dist(go:embed 约束,§8.3-1)
|
||||
# make build 全量构建(前端 + 单二进制)
|
||||
# make linux 交叉编译 linux/amd64
|
||||
# make test Go 测试 + 前端测试
|
||||
# make smoke 真实构建冒烟测试(§8.3-6)
|
||||
# make dev 双进程热重载(air + vite dev)
|
||||
# make vulncheck govulncheck
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
|
||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(DATE)
|
||||
|
||||
.PHONY: web sync-assets build linux test smoke dev vulncheck clean
|
||||
|
||||
web:
|
||||
cd web && npm ci && npm run build
|
||||
|
||||
# go:embed 只能引用本包目录树内文件:产物必须拷进 internal/webui/dist
|
||||
sync-assets:
|
||||
rm -rf internal/webui/dist
|
||||
cp -r web/dist internal/webui/dist
|
||||
touch internal/webui/dist/.gitkeep
|
||||
|
||||
build: web sync-assets
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o pure-note ./cmd/pure-note
|
||||
|
||||
linux: web sync-assets
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="$(LDFLAGS)" \
|
||||
-o pure-note-linux-amd64 ./cmd/pure-note
|
||||
|
||||
test:
|
||||
go vet ./...
|
||||
go test ./...
|
||||
cd web && npm test
|
||||
|
||||
# 构建冒烟(§8.3-6):真实构建 → 起服务 → 断言公开页与 script/link 资源 200 + 正确 MIME
|
||||
smoke: build
|
||||
./scripts/smoke.sh
|
||||
|
||||
dev:
|
||||
air &
|
||||
cd web && npm run dev
|
||||
|
||||
vulncheck:
|
||||
govulncheck ./...
|
||||
|
||||
clean:
|
||||
rm -f pure-note pure-note-linux-amd64
|
||||
@@ -0,0 +1,88 @@
|
||||
# Pure Note
|
||||
|
||||
极简高安全私人笔记 + 博客。最终交付 = **一个二进制程序 + 一个数据文件(目录)**:
|
||||
无数据库服务、无缓存服务、无 Node 运行时、无外部依赖。
|
||||
|
||||
> 设计文档:[docs/design.md](docs/design.md)(v1.1);实施决策记录:[docs/decisions.md](docs/decisions.md)
|
||||
|
||||
## 功能
|
||||
|
||||
- **笔记**:Markdown CRUD、粘贴/拖拽图片上传(≤5MB、魔数校验、BLOB 入库去重)、公开/私有两态、置顶、标签
|
||||
- **博客**:首页(置顶优先 + 分页 + 标签云)、详情(上一篇/下一篇)、标签页、RSS、sitemap、SEO meta 注入(仅公开内容)
|
||||
- **管理**:单管理员口令登录(Argon2id)、会话 7 天滑动续期(CSRF 轮换保持不变)、防爆破限流(per-IP + per-账号)、改密
|
||||
- **回收站**:删除 = 软删除,30 天内可恢复,`gc` 到期物理清除
|
||||
- **运维**:`init` / `serve` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 构建单二进制(前端 + 后端)
|
||||
make build
|
||||
|
||||
# 2. 初始化(设置管理员口令与站点标题)
|
||||
./pure-note init --data-dir ./data
|
||||
# 非交互:PN_ADMIN_PASSWORD=xxx PN_SITE_TITLE=yyy ./pure-note init --data-dir ./data
|
||||
|
||||
# 3. 启动
|
||||
./pure-note serve --addr 127.0.0.1:8080 --data-dir ./data
|
||||
|
||||
# 4. 访问
|
||||
# 博客: http://127.0.0.1:8080/
|
||||
# 管理后台:http://127.0.0.1:8080/admin
|
||||
```
|
||||
|
||||
开发模式(本机调试,Cookie 允许非 Secure,仅限 loopback 监听):
|
||||
|
||||
```bash
|
||||
./pure-note serve --dev --addr 127.0.0.1:8080 --data-dir ./data
|
||||
# 前端热重载(另开终端):
|
||||
cd web && npm install && npm run dev # /api 代理到 127.0.0.1:8080
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
make test # go vet + go test ./... + 前端 vitest
|
||||
make smoke # 构建冒烟:起服务 → SPA 资源 200/MIME → meta 注入 → 可见性抽查
|
||||
```
|
||||
|
||||
测试矩阵覆盖(§13):可见性矩阵(匿名 × 管理员 × {公开, 私有, 回收站, 不存在} × 全部出口)、
|
||||
迁移与 user_version 守卫、登录限流 429、CSRF、上传魔数/去重、回收站生命周期、gc 宽限期、
|
||||
slug 冲突策略、设置白名单(永不泄露口令哈希)、meta 注入转义。
|
||||
|
||||
## 部署(生产)
|
||||
|
||||
```bash
|
||||
make linux # 产出 pure-note-linux-amd64
|
||||
sudo cp pure-note-linux-amd64 /opt/pure-note/pure-note
|
||||
sudo -u purenote ./pure-note init --data-dir /opt/pure-note/data
|
||||
sudo cp deploy/pure-note.service deploy/pure-note-maint.{service,timer} /etc/systemd/system/
|
||||
sudo systemctl enable --now pure-note pure-note-maint.timer
|
||||
```
|
||||
|
||||
反向代理用 Caddy(自动 TLS),示例见 [deploy/Caddyfile](deploy/Caddyfile);
|
||||
启动需带 `--behind-proxy`(取 X-Forwarded-For 最右条目)。
|
||||
|
||||
**升级 SOP**:`pure-note backup` → 停服 → 换二进制 → 起服(迁移自动执行;
|
||||
库版本高于代码支持范围时拒绝启动,`--allow-newer` 显式放行)。
|
||||
|
||||
**恢复 SOP**:停服 → 删除 `data/pure-note.db-wal` 与 `-shm` → 以备份文件替换 `pure-note.db` → 起服。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
cmd/pure-note/ CLI(serve/init/backup/gc/version)
|
||||
internal/config/ 命令行解析(--dev loopback 守卫)
|
||||
internal/store/ SQLite(modernc 纯 Go 驱动):迁移(user_version) + DAO + 备份 + gc
|
||||
internal/auth/ Argon2id(PHC) + 随机 token
|
||||
internal/markdown/ goldmark + bluemonday(RSS/meta 服务端渲染)
|
||||
internal/middleware/ 安全头 / 日志 / 限流 / Origin 校验 / MaxBytes
|
||||
internal/httpapi/ 路由与 handler(公共 / 认证 / 管理 / feed)
|
||||
internal/webui/ go:embed 前端产物 + SPA fallback + index.html meta 注入
|
||||
web/ React 19 + Vite + Tailwind 4 前端
|
||||
deploy/ systemd 单元 ×3 + Caddyfile
|
||||
```
|
||||
|
||||
安全要点(详见设计 §9):CSP `script-src 'self'`、CSRF 双保险(token + Origin 校验)、
|
||||
`__Host-` Cookie、服务端统一可见性过滤(单一可信点)、图片并集可见性、
|
||||
上传魔数校验拒绝 SVG、统一 404 防枚举、安全响应头全家桶。
|
||||
@@ -0,0 +1,7 @@
|
||||
# deploy/Caddyfile — 反代示例(唯一可信代理,自动 TLS)
|
||||
# 程序监听 127.0.0.1:8080,Caddy 追加 X-Forwarded-For,
|
||||
# 服务端以 --behind-proxy 取 XFF 最右条目作为客户端 IP(§14)。
|
||||
example.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# /etc/systemd/system/pure-note-maint.service
|
||||
# 每日维护:先 gc(--commit)后备份(§10.3)
|
||||
[Unit]
|
||||
Description=Pure Note daily maintenance (gc + backup)
|
||||
After=pure-note.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=purenote
|
||||
ExecStart=/opt/pure-note/pure-note gc --data-dir /opt/pure-note/data --commit
|
||||
ExecStart=/opt/pure-note/pure-note backup --data-dir /opt/pure-note/data /backup/pure-note-%%F.db
|
||||
@@ -0,0 +1,10 @@
|
||||
# /etc/systemd/system/pure-note-maint.timer
|
||||
[Unit]
|
||||
Description=Daily Pure Note maintenance (gc + backup)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=03:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,26 @@
|
||||
# /etc/systemd/system/pure-note.service
|
||||
[Unit]
|
||||
Description=Pure Note - minimalist high-security private notes + blog
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=purenote
|
||||
WorkingDirectory=/opt/pure-note
|
||||
ExecStart=/opt/pure-note/pure-note serve --addr 127.0.0.1:8080 \
|
||||
--data-dir /opt/pure-note/data --behind-proxy
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# 沙箱加固(§10.2)
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/pure-note/data
|
||||
MemoryDenyWriteExecute=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -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 常规增强,不构成功能偏离 |
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# 构建冒烟测试(设计 §8.3-6):
|
||||
# 启动二进制 → 创建公开/私有笔记 → 断言首页/深链 HTML 注入 meta、
|
||||
# script/link 以 200 + 正确 MIME 加载(防「深链白屏」类回归)→ 可见性抽查。
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BIN="$ROOT/pure-note"
|
||||
TMP="$(mktemp -d)"
|
||||
PORT="${SMOKE_PORT:-18099}"
|
||||
BASE="http://127.0.0.1:$PORT"
|
||||
FAIL=0
|
||||
|
||||
cleanup() { kill "$SRV_PID" 2>/dev/null || true; rm -rf "$TMP"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
ok() { echo " ✓ $1"; }
|
||||
bad() { echo " ✗ $1"; FAIL=1; }
|
||||
check() { if [ "$2" -eq 0 ]; then ok "$1"; else bad "$1"; fi }
|
||||
|
||||
echo "[smoke] 初始化数据目录"
|
||||
PN_ADMIN_PASSWORD=smoke-password-123 PN_SITE_TITLE="Smoke 站" "$BIN" init --data-dir "$TMP/data"
|
||||
|
||||
echo "[smoke] 启动服务 :$PORT"
|
||||
"$BIN" serve --data-dir "$TMP/data" --addr "127.0.0.1:$PORT" --dev >"$TMP/serve.log" 2>&1 &
|
||||
SRV_PID=$!
|
||||
for _ in $(seq 1 50); do
|
||||
curl -sf "$BASE/api/health" >/dev/null 2>&1 && break
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
echo "[smoke] 健康检查"
|
||||
curl -sf "$BASE/api/health" | grep -q '"ok"'; check "GET /api/health 200 含 ok" $?
|
||||
|
||||
echo "[smoke] 登录并创建笔记"
|
||||
CSRF=$(curl -sf -X POST "$BASE/api/auth/login" -H "Origin: $BASE" \
|
||||
-d '{"password":"smoke-password-123"}' -c "$TMP/cookies" \
|
||||
| sed 's/.*"csrf_token":"\([^"]*\)".*/\1/')
|
||||
[ -n "$CSRF" ]; check "登录获得 CSRF token" $?
|
||||
|
||||
CREATE=$(curl -sf -X POST "$BASE/api/admin/notes" -H "Origin: $BASE" -H "X-CSRF-Token: $CSRF" \
|
||||
-H 'Content-Type: application/json' -b "$TMP/cookies" \
|
||||
-d '{"title":"冒烟笔记 Smoke","content":"# Hello\n\n正文","status":"public"}')
|
||||
echo "$CREATE" | grep -q '"slug"'; check "创建公开笔记" $?
|
||||
SLUG=$(echo "$CREATE" | sed 's/.*"slug":"\([^"]*\)".*/\1/')
|
||||
|
||||
curl -sf -X POST "$BASE/api/admin/notes" -H "Origin: $BASE" -H "X-CSRF-Token: $CSRF" \
|
||||
-H 'Content-Type: application/json' -b "$TMP/cookies" \
|
||||
-d '{"title":"私有机密","content":"秘密","status":"private"}' >/dev/null
|
||||
check "创建私有笔记" $?
|
||||
|
||||
echo "[smoke] SPA 与 meta 注入"
|
||||
HOME_HTML=$(curl -sf "$BASE/")
|
||||
echo "$HOME_HTML" | grep -q '<title>Smoke 站</title>'; check "首页注入站点标题" $?
|
||||
|
||||
DEEP_HTML=$(curl -sf "$BASE/notes/$SLUG")
|
||||
echo "$DEEP_HTML" | grep -q '<title>冒烟笔记 Smoke - Smoke 站</title>'
|
||||
check "深链注入笔记标题(防白屏回归)" $?
|
||||
|
||||
JS=$(echo "$DEEP_HTML" | grep -o '/assets/index-[^"]*\.js' | head -1)
|
||||
[ -n "$JS" ]; check "HTML 含打包 script 引用" $?
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE$JS" || true)
|
||||
[ "$CODE" = "200" ]; check "script 资源 200(实际 $CODE)" $?
|
||||
MIME=$(curl -s -o /dev/null -w '%{content_type}' "$BASE$JS" || true)
|
||||
echo "$MIME" | grep -q 'javascript'; check "script MIME 为 javascript(实际 $MIME)" $?
|
||||
|
||||
CSS=$(echo "$DEEP_HTML" | grep -o '/assets/index-[^"]*\.css' | head -1)
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE$CSS" || true)
|
||||
[ "$CODE" = "200" ]; check "link 样式资源 200(实际 $CODE)" $?
|
||||
MIME=$(curl -s -o /dev/null -w '%{content_type}' "$BASE$CSS" || true)
|
||||
echo "$MIME" | grep -q 'text/css'; check "link MIME 为 css(实际 $MIME)" $?
|
||||
|
||||
echo "[smoke] 安全头抽查"
|
||||
H=$(curl -sI "$BASE/")
|
||||
echo "$H" | grep -qi "Strict-Transport-Security"; check "HSTS" $?
|
||||
echo "$H" | grep -qi "X-Frame-Options: DENY"; check "X-Frame-Options DENY" $?
|
||||
echo "$H" | grep -qi "X-Content-Type-Options: nosniff"; check "nosniff" $?
|
||||
echo "$H" | grep -qi "Content-Security-Policy.*script-src 'self'"; check "CSP script-src 'self'" $?
|
||||
|
||||
echo "[smoke] 可见性抽查(私有不可见)"
|
||||
if curl -sf "$BASE/api/notes" | grep -q '私有机密'; then bad "匿名列表不含私有笔记"; else ok "匿名列表不含私有笔记"; fi
|
||||
if curl -sf "$BASE/feed.xml" | grep -q '私有机密'; then bad "RSS 不含私有笔记"; else ok "RSS 不含私有笔记"; fi
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/notes/$SLUG")
|
||||
[ "$CODE" = "200" ]; check "公开笔记详情匿名 200(实际 $CODE)" $?
|
||||
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo "[smoke] 全部通过 ✓"
|
||||
else
|
||||
echo "[smoke] 存在失败项 ✗"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user