From 183676428ecb45d5ed2aae5496f6a57a2dc42b1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A0=81=E5=86=9C=E9=98=BF=E6=A5=A0?= Date: Tue, 8 Sep 2026 08:15:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9E=84=E5=BB=BA=E3=80=81=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E4=B8=8E=E9=AA=8C=E6=94=B6=E6=96=87=E6=A1=A3=EF=BC=9AMakefile?= =?UTF-8?q?=E3=80=81=E5=86=92=E7=83=9F=E8=84=9A=E6=9C=AC=E3=80=81systemd/C?= =?UTF-8?q?addy=E3=80=81=E5=86=B3=E7=AD=96=E4=B8=8E=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Makefile | 53 ++++++++++++++++++++ README.md | 88 ++++++++++++++++++++++++++++++++ deploy/Caddyfile | 7 +++ deploy/pure-note-maint.service | 11 ++++ deploy/pure-note-maint.timer | 10 ++++ deploy/pure-note.service | 26 ++++++++++ docs/acceptance.md | 91 ++++++++++++++++++++++++++++++++++ docs/decisions.md | 65 ++++++++++++++++++++++++ scripts/smoke.sh | 91 ++++++++++++++++++++++++++++++++++ 9 files changed, 442 insertions(+) create mode 100644 Makefile create mode 100644 README.md create mode 100644 deploy/Caddyfile create mode 100644 deploy/pure-note-maint.service create mode 100644 deploy/pure-note-maint.timer create mode 100644 deploy/pure-note.service create mode 100644 docs/acceptance.md create mode 100644 docs/decisions.md create mode 100755 scripts/smoke.sh diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..95cbf56 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..44a77ce --- /dev/null +++ b/README.md @@ -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 防枚举、安全响应头全家桶。 diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..a1f01f4 --- /dev/null +++ b/deploy/Caddyfile @@ -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 +} diff --git a/deploy/pure-note-maint.service b/deploy/pure-note-maint.service new file mode 100644 index 0000000..b34ce4c --- /dev/null +++ b/deploy/pure-note-maint.service @@ -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 diff --git a/deploy/pure-note-maint.timer b/deploy/pure-note-maint.timer new file mode 100644 index 0000000..6e23942 --- /dev/null +++ b/deploy/pure-note-maint.timer @@ -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 diff --git a/deploy/pure-note.service b/deploy/pure-note.service new file mode 100644 index 0000000..2efacd5 --- /dev/null +++ b/deploy/pure-note.service @@ -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 diff --git a/docs/acceptance.md b/docs/acceptance.md new file mode 100644 index 0000000..f517f98 --- /dev/null +++ b/docs/acceptance.md @@ -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)。 diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..a76ab3f --- /dev/null +++ b/docs/decisions.md @@ -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 | 编辑器工具栏提供加粗/斜体/链接/代码/表格插入;图片粘贴/拖拽上传后插入 `![name](/api/images/{id})` | §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 常规增强,不构成功能偏离 | diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..7a727ed --- /dev/null +++ b/scripts/smoke.sh @@ -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 'Smoke 站'; check "首页注入站点标题" $? + +DEEP_HTML=$(curl -sf "$BASE/notes/$SLUG") +echo "$DEEP_HTML" | grep -q '冒烟笔记 Smoke - Smoke 站' +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