初始提交:Pure Note 设计方案 v1.1(含第 1 轮审评报告与修订)
- docs/design.md: 技术调研与设计方案,已按 review-round1.md 的 24 项 审评发现(P0x4/P1x11/P2x9)完整修订 - docs/review-round1.md: 第 1 轮审评报告(留档) - .gitignore 与 internal/webui/dist 占位
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
|||||||
|
# 数据目录(SQLite 库、备份)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# 前端依赖与构建产物
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
|
||||||
|
# go:embed 目标目录(Makefile sync-assets 生成;仅保留 .gitkeep 占位)
|
||||||
|
internal/webui/dist/*
|
||||||
|
!internal/webui/dist/.gitkeep
|
||||||
|
|
||||||
|
# 本地构建的二进制
|
||||||
|
/pure-note
|
||||||
|
/pure-note-linux-*
|
||||||
|
|
||||||
|
# 系统文件
|
||||||
|
.DS_Store
|
||||||
+724
@@ -0,0 +1,724 @@
|
|||||||
|
# Pure Note — 极简高安全私人笔记 + 博客 设计方案
|
||||||
|
|
||||||
|
> 版本:v1.1(第 1 轮审评修订版)
|
||||||
|
> 日期:2026-09-08
|
||||||
|
> 状态:已按 docs/review-round1.md 全部 24 项发现修订(P0×4 / P1×11 / P2×9),可进入 M0 开发
|
||||||
|
> 调研数据来源:pkg.go.dev / npm registry / GitHub API 在线核实(2026-09-07)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目概述与目标
|
||||||
|
|
||||||
|
### 1.1 定位
|
||||||
|
|
||||||
|
一款部署在公网服务器上的私人笔记 + 博客程序,B/S 架构:
|
||||||
|
|
||||||
|
- **给自己用**:Markdown 笔记,支持内嵌图片,内容只分「公开 / 私有」两种;
|
||||||
|
- **给外部看**:公开笔记自动构成一个博客站点,可匿名浏览;
|
||||||
|
- **运营极简**:最终交付 = **一个二进制程序 + 一个数据文件(目录)**,无数据库服务、无缓存服务、无 Node 运行时、无外部依赖。
|
||||||
|
|
||||||
|
### 1.2 核心原则
|
||||||
|
|
||||||
|
| 原则 | 落实手段 |
|
||||||
|
| --- | --- |
|
||||||
|
| 安全性极高 | 全链路默认安全:Argon2id 口令哈希、双层 Markdown 清洗防 XSS、严格 CSP、CSRF 双保险、上传魔数校验、最小权限模型,详见 §9 |
|
||||||
|
| 架构极简 | 纯 Go 标准库 HTTP + 5 个直接依赖;SQLite 单文件;前端 SPA 用 `go:embed` 打进二进制;无 ORM、无框架、无微服务 |
|
||||||
|
| 单二进制交付 | CGO 关停、纯 Go SQLite 驱动(modernc.org/sqlite)、`web/dist` 经 Makefile 拷贝到可嵌入目录(§8.3),交叉编译一键出包 |
|
||||||
|
| 权限体系简单健壮 | 笔记仅两种状态(public/private);唯一管理员账号持有编辑权;匿名用户只见 public;服务端统一可见性过滤,不依赖前端隐藏 |
|
||||||
|
| 数据可恢复 | 删除 = 回收站软删除(30 天);每日在线备份;升级前强制备份 |
|
||||||
|
|
||||||
|
### 1.3 目标 / 非目标
|
||||||
|
|
||||||
|
**目标(IN SCOPE)**
|
||||||
|
|
||||||
|
- Markdown 笔记 CRUD,内嵌图片(粘贴 / 拖拽上传),公开/私有切换;
|
||||||
|
- 博客展示:首页列表、笔记详情、标签归档、RSS、sitemap;
|
||||||
|
- 单人管理员,登录后编辑;匿名只读公开内容;管理员可在博客皮肤预览私有笔记;
|
||||||
|
- 回收站:删除走软删除,30 天内可恢复,`gc` 到期清除;
|
||||||
|
- 单机部署:systemd / 反代(Caddy 自动 TLS)。
|
||||||
|
|
||||||
|
**非目标(OUT OF SCOPE,预留演进位)**
|
||||||
|
|
||||||
|
- 多用户、多人协作、评论系统(可后续加,不影响本设计);
|
||||||
|
- 全文搜索(SQLite FTS5 可平滑引入,预留);
|
||||||
|
- 版本历史/编辑快照(回收站只保证「误删可恢复」,不做多版本);
|
||||||
|
- 多实例集群、CDN 同步(单实例架构,见 §11 边界说明);
|
||||||
|
- 多端实时同步/移动客户端。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 需求分析
|
||||||
|
|
||||||
|
### 2.1 功能需求
|
||||||
|
|
||||||
|
| 编号 | 模块 | 需求 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| F1 | 笔记管理 | 创建/编辑 Markdown 笔记;标题、slug、标签、置顶;草稿即「私有」 |
|
||||||
|
| F2 | 权限 | 笔记只有 `public` / `private` 两态;切换即时生效;私有内容任何匿名途径(含 API、标签、RSS、图片直链、HTML meta)不可见 |
|
||||||
|
| F3 | 图片 | 编辑器内粘贴/拖拽上传(≤5MB);Markdown 中引用;图片可见性 = 引用它的笔记可见性的并集;去重存储 |
|
||||||
|
| F4 | 博客 | 匿名访问:首页(公开列表+分页+标签)、详情页、标签页;RSS Feed;sitemap.xml;SEO 基础(仅公开笔记注入 title/description/og meta) |
|
||||||
|
| F5 | 认证 | 单管理员密码登录/登出/**改密**;会话失效/续期;登录与改密防爆破 |
|
||||||
|
| F6 | 站点设置 | 站点标题、副标题、每页条数(白名单字段,绝不含口令哈希) |
|
||||||
|
| F7 | 回收站 | 删除 = 软删除进入回收站;30 天内可恢复;`gc` 到期物理清除 |
|
||||||
|
| F8 | 运维 | 单二进制启动;`--data-dir` 指定数据目录;在线备份子命令;`gc` 子命令(默认 dry-run);版本号 |
|
||||||
|
|
||||||
|
### 2.2 非功能需求
|
||||||
|
|
||||||
|
| 类别 | 要求 |
|
||||||
|
| --- | --- |
|
||||||
|
| 安全 | OWASP 常见风险全覆盖(XSS/CSRF/注入/会话/上传/点击劫持/MIME 嗅探),上线前过 §9.3 检查单,其中可见性矩阵长期由自动化测试守护(§13) |
|
||||||
|
| 性能 | 个人规模(<10k 笔记、<100k 图片):P95 页面响应 < 300ms;SQLite 游刃有余 |
|
||||||
|
| 可靠性 | 崩溃不损坏数据(WAL);每日自动备份;升级可回滚;优雅停机不丢在途写入 |
|
||||||
|
| 极简 | 直接依赖 ≤6(Go 侧 5 个);无 Node 服务端运行时;运维只需 systemd 单元 ×2(服务 + 每日 gc/备份 timer)+ 反代配置 |
|
||||||
|
| 可维护 | 前后端分离清晰;API 稳定;Markdown 渲染为可替换组件;核心不变量有自动化回归 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 技术选型调研结论
|
||||||
|
|
||||||
|
> 以下版本号均为 2026-09-07 经 pkg.go.dev、npm registry、GitHub API 在线核实。
|
||||||
|
|
||||||
|
### 3.1 后端选型
|
||||||
|
|
||||||
|
| 组件 | 选择 | 版本 | 理由 | 备选(被否决原因) |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 语言 | Go | 1.27.1 | 单一静态二进制、交叉编译、内存安全、并发模型适合 HTTP 服务 | — |
|
||||||
|
| HTTP 路由 | 标准库 `net/http` + 1.22 增强 ServeMux | — | 方法与通配符路由足够支撑小型 JSON API,**零框架依赖** | chi(需要中间件生态时再引入)、gin/echo(非必需) |
|
||||||
|
| SQLite 驱动 | `modernc.org/sqlite` | v1.58.0 | **纯 Go、无 CGO**,内嵌 SQLite 3.53.4,跨平台静态编译,3500+ 依赖方生产可用 | mattn/go-sqlite3(需要 CGO_ENABLED=1 + gcc,破坏单二进制目标) |
|
||||||
|
| Markdown 解析(服务端) | `goldmark` | v1.8.6 | CommonMark 兼容、默认转义原始 HTML 与危险 URL(XSS 安全默认)、GFM/脚注/排版扩展齐全、v1 线成熟稳定 | gomarkdown(仍在 v0)、blackfriday(2020 年停更);goldmark v2.0.1 刚发布,稳定后评估升级 |
|
||||||
|
| HTML 清洗(服务端) | `bluemonday` | v1.0.27 | 白名单式清洗标准方案;与 goldmark 双保险 | — |
|
||||||
|
| 口令哈希 | `golang.org/x/crypto/argon2` | v0.56.0 | **Argon2id**,OWASP 现行参数 m=19456(19MiB)、t=2、p=1;按 **PHC 字符串** 编码存储(§7.3) | bcrypt(同样可接受,但 Argon2 为当前首选) |
|
||||||
|
| 会话 | **自建 DB 会话表**(~80 行) | — | crypto/rand 256bit token + SHA-256 摘要入库,可撤销、可过期清理、零额外依赖;单管理员场景完全可控 | gorilla/sessions v1.4.0(已恢复维护)、scs v2.9.0(轻量备选,含 SQLite store) |
|
||||||
|
| 限流 | `golang.org/x/time/rate` | v0.15.0 | 令牌桶,登录/改密防爆破够用(per-IP + per-账号双维度);桶有数量上限与 TTL 清理(§7.2) | Redis 方案(多实例才需要) |
|
||||||
|
| 静态资源 | `go:embed` + `http.FS` | — | **embed 只能引用本包目录树内文件**:产物经 Makefile 拷贝到 `internal/webui/dist` 再嵌入(§8.3);自写 SPA fallback handler | 外置静态目录 + nginx(违背单二进制) |
|
||||||
|
| 数据访问 | 标准库 `database/sql` 参数化查询 | — | 无 ORM,6 张表手写 SQL 最透明 | GORM 等(依赖重、隐藏细节) |
|
||||||
|
|
||||||
|
### 3.2 前端选型
|
||||||
|
|
||||||
|
| 组件 | 选择 | 版本 | 理由 | 备选 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 框架 | React | 19.2.8 | 生态最大、md 渲染/编辑器组件最丰富 | — |
|
||||||
|
| 语言 | TypeScript | 7.x(不动则 5.9) | 原生编译器版已可用;若第三方库兼容有问题回退 5.9 | — |
|
||||||
|
| 构建 | Vite | 8.2.2 | 事实标准;部署在域名根,`base` 用默认值 `'/'`(相对 base 与 SPA 深链冲突,见 §8.3 与审评报告 P0-1) | — |
|
||||||
|
| 样式/组件 | Tailwind CSS 4 + **shadcn/ui** | 4.3.3 / CLI 4.21.0 | CSS-first 无样板配置;shadcn 复制式组件零运行时、克制美观,正合内容型博客;按需叠加 Radix 原语 | Ant Design 6(重、后台味浓)、Chakra 3、HeroUI、Radix Themes |
|
||||||
|
| 路由 | react-router | 8.3.1 | library mode 成熟,博客+后台两个区足够 | TanStack Router 1.x(类型安全更强,但本项目路由简单) |
|
||||||
|
| 数据请求 | TanStack Query | 5.102.8 | 十几 kB 换来统一 loading/缓存/重试,值得 | 裸 fetch + 自写 hook |
|
||||||
|
| Markdown 渲染 | react-markdown + remark-gfm + rehype-sanitize + **rehype-highlight** | 10.1.0 / 4.0.1 / 6.0.0 / 7.0.2 | 组合标准;高亮选 rehype-highlight(**纯 class 输出、零内联样式**)而非 shiki(inline style),与严格 CSP 兼容 | shiki 4.4.3(准确度最高但产 inline style)、prism-react-renderer(近停更,不推荐) |
|
||||||
|
| Markdown 编辑器 | **CodeMirror 6**(@uiw/react-codemirror + @codemirror/lang-markdown) | 4.25.11 / 6.5.2 | 源码编辑 + 分屏实时预览,零魔法、可预期;图片粘贴自写 handler(≤50 行) | Tiptap 3.31(体验派,WYSIWYG+成熟图片上传,文档全);MDXEditor(重);ByteMD(字节已弃坑)、Vditor 4(体积大、心智旧) |
|
||||||
|
| 状态管理 | React Query + Context(**不引入** zustand) | — | 会话态 + 服务端缓存已覆盖;确需跨页 UI 态再加 zustand(≈1kB) | redux(不必要) |
|
||||||
|
| 图标 | lucide-react | 1.42.0 | 活跃、风格统一、与 shadcn 默认匹配 | — |
|
||||||
|
|
||||||
|
### 3.3 关键决策记录(ADR 摘要)
|
||||||
|
|
||||||
|
1. **图片存 SQLite BLOB 而不是文件系统** —— 满足「只有一个数据文件」字面含义:备份=拷一个文件;事务一致性;杜绝路径穿越整类风险(文件永不落盘执行)。个人博客场景(单图 ≤5MB、总量 2GB 内)SQLite 毫无压力(理论上限 281TB)。>10MB 的图片一律拒绝引导压缩。
|
||||||
|
2. **SPA 而非 SSR** —— 管理端+浏览端共用 React 技术栈,极简;SEO 用「服务端渲染 index.html 元信息 + RSS + sitemap」弥补,元信息注入与内容 API 同一条可见性规则(§8.3)。「公开页 Go 模板 SSR」留作 v2 备选(见 §14)。
|
||||||
|
3. **HTTP 用标准库** —— Go 1.22+ ServeMux 已具备方法路由与 `{param}` 通配符;本项目 API 约 20 个路由,框架收益趋近于零,依赖面以「less is more」取胜。
|
||||||
|
4. **会话自建而非引库** —— 单管理员、单表、几十行代码,审计面最小;需求升级时切 scs 的成本极低。
|
||||||
|
5. **图片可见性用「引用表」+ 并集语义** —— 图片通过 `image_refs` 关联笔记,可见性 = 所有引用笔记可见性的**并集**(任一 public 引用即整体公开),并显式声明「已公开过的图片视为已分发,转私无法撤回已泄露副本」(§6.2、§9.1)。
|
||||||
|
6. **编辑器选 CodeMirror 6 路线** —— 与「极简健壮」一致:所见即所写,不需要维护富文本中间态;组件边界(MarkdownViewer 独立)保证日后可平替 Tiptap。
|
||||||
|
7. **回收站用软删除(`deleted_at` 列)而非快照表** —— 删除不改动 `image_refs`,可见性过滤统一加 `deleted_at IS NULL` 一个条件即可覆盖全部出口;恢复 = 清空该列,图片引用天然保全;`gc` 到期硬删时引用级联(§6.2)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 总体架构
|
||||||
|
|
||||||
|
### 4.1 架构图
|
||||||
|
|
||||||
|
```
|
||||||
|
公网
|
||||||
|
│ HTTPS (Caddy 自动 TLS, 反代, 追加密名 XFF)
|
||||||
|
▼
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ pure-note 单一二进制 (Go) │
|
||||||
|
│ │
|
||||||
|
│ net/http + ServeMux │
|
||||||
|
│ ├─ 公共 API /api/* │──┐
|
||||||
|
│ ├─ 管理 API /api/admin/*│ │ (匿名 or 会话)
|
||||||
|
│ ├─ /feed.xml /sitemap │ │
|
||||||
|
│ └─ SPA (go:embed │ │
|
||||||
|
│ internal/webui/dist) │
|
||||||
|
│ │ │
|
||||||
|
│ 中间件链: │ │
|
||||||
|
│ SecurityHeaders → 日志 → 限流 → │
|
||||||
|
│ Origin 校验 → MaxBytes → Auth → CSRF → Handler │ │
|
||||||
|
└────────────┬─────────────┘ │
|
||||||
|
│ database/sql (SetMaxOpenConns=1)
|
||||||
|
▼
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ data/pure-note.db (SQLite)│
|
||||||
|
│ notes(含 deleted_at) / │
|
||||||
|
│ images(BLOB) / image_refs /│
|
||||||
|
│ sessions / settings │
|
||||||
|
└──────────────────────────┘
|
||||||
|
|
||||||
|
浏览器: React SPA (公开浏览 + 管理后台)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 数据流
|
||||||
|
|
||||||
|
**公开读路径**:访客浏览器 → `GET /api/notes/{slug}` → 服务端以 `status='public' AND deleted_at IS NULL`(或有效管理员会话)过滤 → 返回原始 Markdown + 元信息 → 前端 react-markdown 管线(GFM → rehype-sanitize 清洗 → highlight class 高亮)渲染。所有内容经 CSP 约束。
|
||||||
|
|
||||||
|
**管理写路径**:管理员登录(Argon2id 校验、限流、Origin 校验)→ 会话 Cookie(`__Host-` 前缀)+ CSRF Token(内存持有,刷新经 `/api/me` 重取)→ 编辑/上传(multipart 魔数校验 → BLOB 入库 + sha256 去重)→ 保存笔记时事务内重建 `image_refs` 引用。
|
||||||
|
|
||||||
|
**图片读取路径**:`GET /api/images/{id}` → 查 `image_refs` 关联笔记的可见性并集(任一 `public` 且未删除即可匿名访问,否则要求管理员会话)→ 按可见性返回不同缓存头(公开 `immutable` / 非公开 `no-store`)→ 未授权与不存在统一 404。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 目录结构(Monorepo)
|
||||||
|
|
||||||
|
```
|
||||||
|
pure-note/
|
||||||
|
├── go.mod / go.sum
|
||||||
|
├── Makefile
|
||||||
|
├── .gitignore # data/ web/node_modules/ web/dist/ internal/webui/dist/(仅保留 .gitkeep)
|
||||||
|
├── README.md
|
||||||
|
├── docs/
|
||||||
|
│ ├── design.md # 本文档(v1.1)
|
||||||
|
│ └── review-round1.md # 第 1 轮审评报告(修订依据,留档)
|
||||||
|
├── cmd/
|
||||||
|
│ └── pure-note/
|
||||||
|
│ └── main.go # 入口 + 命令行(serve/init/backup/gc/version)
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # 环境变量/flag 解析
|
||||||
|
│ ├── store/ # SQLite 打开、迁移(user_version 上界校验)、DAO
|
||||||
|
│ ├── httpapi/ # 路由注册、handler
|
||||||
|
│ │ ├── public.go / admin.go / auth.go / image.go / feed.go
|
||||||
|
│ ├── middleware/ # securityHeaders / rateLimit / originCheck / auth / csrf(自写)
|
||||||
|
│ ├── auth/ # argon2id(PHC)、会话 token、密码校验
|
||||||
|
│ ├── markdown/ # goldmark 渲染(RSS/meta 用)+ bluemonday 清洗
|
||||||
|
│ └── webui/ # go:embed dist + SPA fallback + index.html 元信息注入
|
||||||
|
│ └── dist/ # 【生成目录】Makefile 自 web/dist 拷贝,.gitignore(仓库保留 .gitkeep 供 M0 编译)
|
||||||
|
├── web/ # React 前端(独立 Vite 工程)
|
||||||
|
│ ├── package.json / vite.config.ts
|
||||||
|
│ ├── index.html
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.tsx / App.tsx / router.tsx
|
||||||
|
│ ├── lib/ # api client、auth、csrf、格式化
|
||||||
|
│ ├── components/ # shadcn/ui 生成目录 + 业务组件
|
||||||
|
│ │ ├── markdown/ # MarkdownViewer(渲染管线,可替换)
|
||||||
|
│ │ └── editor/ # Editor(CodeMirror)+ ImagePaste + Toolbar
|
||||||
|
│ ├── features/ # notes / admin 业务 hooks
|
||||||
|
│ └── pages/ # Home / Note / Tags / AdminLogin / AdminList / AdminTrash / AdminEdit
|
||||||
|
└── deploy/
|
||||||
|
├── pure-note.service # systemd 服务单元
|
||||||
|
├── pure-note-maint.timer # 每日 gc + 备份 timer
|
||||||
|
└── Caddyfile # 反代示例
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 数据模型(SQLite)
|
||||||
|
|
||||||
|
### 6.1 Schema(DDL)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 连接串(DSN)统一注入以下 pragma:
|
||||||
|
-- file:data/pure-note.db?_pragma=journal_mode(WAL)
|
||||||
|
-- &_pragma=busy_timeout(5000)
|
||||||
|
-- &_pragma=foreign_keys(1)
|
||||||
|
-- &_pragma=synchronous(NORMAL)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
slug TEXT NOT NULL UNIQUE, -- URL 标识;首次保存定稿(§8.2)
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
summary TEXT NOT NULL DEFAULT '', -- 列表页摘要,可空则截取正文
|
||||||
|
content TEXT NOT NULL DEFAULT '', -- Markdown 原文
|
||||||
|
status TEXT NOT NULL DEFAULT 'private'
|
||||||
|
CHECK (status IN ('public','private')),
|
||||||
|
tags TEXT NOT NULL DEFAULT '[]', -- JSON 字符串数组
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted_at INTEGER, -- NULL=正常;非空=回收站(软删除)
|
||||||
|
created_at INTEGER NOT NULL, -- Unix 秒
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_public
|
||||||
|
ON notes (status, pinned, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_deleted ON notes (deleted_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS images (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sha256 TEXT NOT NULL UNIQUE, -- 内容哈希:去重 + ETag
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS image_refs ( -- 笔记 ↔ 图片多对多
|
||||||
|
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||||
|
note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (image_id, note_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY, -- SHA-256(token 明文),库里不存明文
|
||||||
|
csrf_token TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions (expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
-- 键白名单(§7.1 SettingsDTO 同源):
|
||||||
|
-- admin_password_hash : PHC 串 $argon2id$v=19$m=19456,t=2,p=1$<b64salt>$<b64hash>
|
||||||
|
-- site_title / site_desc / page_size
|
||||||
|
-- admin_password_hash 永不进入任何 API 响应、不可经 settings 接口写入(§9.3)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 关键策略
|
||||||
|
|
||||||
|
| 事项 | 策略 |
|
||||||
|
| --- | --- |
|
||||||
|
| 图片去重 | 上传按 `sha256` 查重,同图复用同一行 |
|
||||||
|
| 图片可见性(并集语义) | 匿名可读 ⇔ `∃ ref → note.status='public' AND note.deleted_at IS NULL`;否则仅管理员。**可见性取所有引用笔记的并集**:同一字节的图一旦被任何公开笔记引用即整体公开;「已公开过的图片视为已分发,转私/删除无法撤回已被缓存、外链的副本」——按已知边界接受(§9.1-T10) |
|
||||||
|
| 引用维护 | 保存笔记时在**同一事务**内:删除该笔记全部 refs → 用正则扫描 `content` 中的 `/api/images/(\d+)` → 重建 refs |
|
||||||
|
| 回收站(软删除) | DELETE 仅置 `deleted_at`;refs 不动(恢复时图片天然保全);所有可见性过滤统一追加 `deleted_at IS NULL`;`gc` 将 `deleted_at < now−30天` 物理删除(级联 refs);回收期内 slug 仍被占用(保身份,见 §14) |
|
||||||
|
| 会话 | 随机 256bit token(crypto/rand)→ Cookie 存 token 明文,库存 SHA-256;登录成功即重建行(防会话固定);轮换时 **csrf_token 保持不变**;启动时 + 每小时清理过期行 |
|
||||||
|
| 迁移 | `PRAGMA user_version` 版本化,顺序执行内嵌迁移脚本;**仅追加式**(禁止删列/重命名/改类型);启动时校验 `user_version ≤ 代码支持的最高版本`,超出拒绝启动(`--allow-newer` 显式放行,§10.4) |
|
||||||
|
| 并发 | `db.SetMaxOpenConns(1)` — 单写者串行化,WAL 下个人规模足够;规模上来后按「多读者连接池」扩展 |
|
||||||
|
|
||||||
|
### 6.3 数据清理(`pure-note gc`)
|
||||||
|
|
||||||
|
默认 `--dry-run` 只输出清理计划,`--commit` 才执行;systemd timer 每日调用一次(§10.3):
|
||||||
|
|
||||||
|
1. 物理删除 `deleted_at < now−30天` 的笔记(refs 级联);
|
||||||
|
2. 删除图片:0 引用 **且** `created_at < now−7天`(宽限期防止误删编辑中刚上传的图);
|
||||||
|
3. 删除过期会话。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 后端设计
|
||||||
|
|
||||||
|
### 7.1 HTTP API 一览
|
||||||
|
|
||||||
|
> 统一约定:JSON `{"data": ..., "error": {"code": "...", "message": "..."}}`;状态码语义化(400/401/403/404/409/413/429/500);分页参数 `page ∈ [1,10⁴]`、`page_size ∈ [1,100]`(默认 10),越界返回 400。
|
||||||
|
|
||||||
|
**公共(匿名 + 管理员)**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/health` | 健康检查 |
|
||||||
|
| GET | `/api/me` | 匿名 `{authenticated:false}`;已认证 `{authenticated:true, csrf_token}`(刷新后 CSRF token 重取点,§7.3) |
|
||||||
|
| GET | `/api/notes` | 公开笔记列表(`status='public' AND deleted_at IS NULL`),`?page=&page_size=&tag=`,返回元信息(不含全文) |
|
||||||
|
| GET | `/api/notes/{slug}` | 笔记详情:**`public` 或有效管理员会话**可读(响应含 `status` 字段供前端显示「私有预览」);其余统一 404 |
|
||||||
|
| GET | `/api/tags` | 标签聚合(**仅统计公开且未删除的笔记**) |
|
||||||
|
| GET | `/api/images/{id}` | 图片(并集可见性,§6.2);未授权与不存在**统一 404**(防 ID 枚举);缓存头按可见性分流(§7.4) |
|
||||||
|
| GET | `/feed.xml` | RSS 2.0(仅公开;服务端 goldmark+bluemonday 渲染) |
|
||||||
|
| GET | `/sitemap.xml` | 公开笔记 URL 列表 |
|
||||||
|
| GET | `/robots.txt` | 允许全部 + sitemap 指向 |
|
||||||
|
| GET | `/` 及所有非 API 路径 | SPA fallback → 内嵌 index.html(**仅对可见笔记**注入 title/desc/og meta,§8.3) |
|
||||||
|
|
||||||
|
**管理(会话 + CSRF)**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST | `/api/auth/login` | `{password}`;Origin 校验 + 限流 + 常量时间比较;返回 CSRF token |
|
||||||
|
| POST | `/api/auth/logout` | 删除会话行 |
|
||||||
|
| GET | `/api/admin/notes` | 全部正常笔记(含私有,不含回收站) |
|
||||||
|
| POST | `/api/admin/notes` | 新建;自动生成唯一 slug(自解冲突,§8.2) |
|
||||||
|
| GET | `/api/admin/notes/{id}` | 单篇(含私有) |
|
||||||
|
| PUT | `/api/admin/notes/{id}` | 更新(事务内重建 image_refs);slug 冲突返回 409 + 字段级错误 |
|
||||||
|
| DELETE | `/api/admin/notes/{id}` | **软删除**:置 `deleted_at` 进入回收站 |
|
||||||
|
| GET | `/api/admin/trash` | 回收站列表 |
|
||||||
|
| POST | `/api/admin/trash/{id}/restore` | 恢复:清空 `deleted_at`(slug 仍被自身占用,无冲突;外部占用则在 30 天内不可能,见 §6.2) |
|
||||||
|
| POST | `/api/admin/images` | multipart 上传 → `{id, url}`;≤5MB |
|
||||||
|
| GET | `/api/admin/images?orphan=1` | 0 引用图片清单(删除前检视,实际清除由 gc 执行) |
|
||||||
|
| GET/PUT | `/api/admin/settings` | **SettingsDTO 白名单**(site_title/site_desc/page_size),读永不序列化 `admin_password_hash`、写只收白名单键 |
|
||||||
|
| POST | `/api/admin/password` | `{old_password, new_password}`;校验旧密码(常量时间)、新密码 ≥12 字符;复用登录限流 |
|
||||||
|
|
||||||
|
### 7.2 中间件链与服务器参数
|
||||||
|
|
||||||
|
```
|
||||||
|
SecurityHeaders(含 HSTS,§9.2)
|
||||||
|
→ slog 请求日志(仅错误 + 管理操作,登录失败记 IP 与桶计数)
|
||||||
|
→ 全局限流(宽松令牌桶;per-IP 桶上限 4096 条 + 10 分钟 TTL 逐出,防伪造 IP 撑爆内存)
|
||||||
|
→ Origin/Referer→Host 校验(**所有** 非 GET/HEAD/OPTIONS 请求,含 /api/auth/*,§9.1-T2)
|
||||||
|
→ 路由级 MaxBytesReader(auth/settings ≤64KB;notes JSON ≤1MB;multipart ≤6MB 含边界开销)
|
||||||
|
→ 路由
|
||||||
|
├─ 公共路由: handler 内 status='public' AND deleted_at IS NULL 过滤
|
||||||
|
├─ /api/auth/login: 严格限流(per-IP 10次/5分钟 + per-账号 5次/10分钟,429 + Retry-After)
|
||||||
|
└─ /api/admin/*: 会话校验(401) → CSRF 头校验(403) → handler;响应统一 Cache-Control: no-store
|
||||||
|
```
|
||||||
|
|
||||||
|
`http.Server`:`ReadHeaderTimeout: 5s, ReadTimeout: 120s, WriteTimeout: 120s, IdleTimeout: 120s`(slowloris 与超大 body 防线)。优雅停机:SIGTERM/SIGINT → 停止接受新连接 → `Shutdown(ctx 10s)` 排空在途请求 → 关闭 SQLite 连接(§10.5)。
|
||||||
|
|
||||||
|
### 7.3 认证与会话
|
||||||
|
|
||||||
|
1. **初始化**:`pure-note init`(交互式设密码,或环境变量 `PN_ADMIN_PASSWORD` 非交互)→ Argon2id(m=19456, t=2, p=1, salt=16B 随机, keyLen=32B) **以 PHC 串 `$argon2id$v=19$m=19456,t=2,p=1$<b64salt>$<b64hash>` 存库**——参数随哈希走,未来调参可继续校验旧口令;
|
||||||
|
2. **登录**:Origin/Referer→Host 校验通过后,per-IP 10 次/5 分钟 + 账号维度 5 次/10 分钟令牌桶(只计失败尝试);校验用 `subtle.ConstantTimeCompare`;失败统一返回 401「用户名或密码错误」不泄露差异;
|
||||||
|
3. **会话 Cookie**:`__Host-pn_session` = 256bit 随机 token;属性 `Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=7d`(`__Host-` 前缀自带 Secure + 根路径约束);
|
||||||
|
4. **CSRF**:会话行内存随机 `csrf_token`,登录响应下发、前端仅存内存并附到所有 `/api/admin/*` 变更请求的 `X-CSRF-Token` 头;**刷新页面后经 `GET /api/me` 重取**;明确禁止任何 localStorage 持久化;服务端同时校验 Origin/Referer 与 Host 匹配;
|
||||||
|
5. **轮换**:活跃访问距过期 < 3 天时重建会话行、旋转 Cookie token,**csrf_token 保持不变**(前端无感);
|
||||||
|
6. **登出**:删除会话行;
|
||||||
|
7. **改密**:`POST /api/admin/password` 校验旧密码后写新 PHC 哈希;改密不失效当前会话(可选:改密后删除其他会话——单管理员场景仅本会话,不强制);
|
||||||
|
8. **开发模式**:`--dev` 允许非 Secure Cookie(否则 `__Host-` 在 `http://局域网IP` 下无法登录);**强制约束:仅当监听地址为 loopback 时允许启动,防止误部署到生产**。
|
||||||
|
|
||||||
|
### 7.4 图片上传与访问
|
||||||
|
|
||||||
|
1. 上传:`Content-Type` 白名单 + **魔数字节校验**(PNG/JPEG/WebP/GIF 签名,不信任客户端声明;**SVG 一律拒绝**——携带脚本风险)→ ≤5MB → 读入内存计算 sha256 → `INSERT OR IGNORE` 去重 → 返回 URL;图片**永不落盘**,杜绝 WebShell 执行面;
|
||||||
|
2. 响应头按可见性分流:
|
||||||
|
- **公开图**:`Content-Type`(入库 mime)、`X-Content-Type-Options: nosniff`、`ETag: "<sha256>"`、`Cache-Control: public, max-age=31536000, immutable`;
|
||||||
|
- **非公开图**(仅私有笔记引用/孤儿/仅管理员会话):`Cache-Control: private, no-store`,**每次请求重新执行可见性判定**;
|
||||||
|
- **未授权与不存在统一 404**(防自增 ID 枚举);
|
||||||
|
3. 可选增强(P2):EXIF/元数据剥离。
|
||||||
|
|
||||||
|
### 7.5 Markdown 服务端职责
|
||||||
|
|
||||||
|
- **RSS / meta 渲染**:goldmark(GFM 扩展、`html.WithUnsafe=false` 保持转义)→ bluemonday 白名单清洗 → 摘要截断;
|
||||||
|
- **不把 HTML 存库**:库中始终只存 Markdown 原文,渲染确定性交给前端管线,安全策略(清洗规则)升级时新旧笔记全部收益。
|
||||||
|
|
||||||
|
### 7.6 配置与命令行
|
||||||
|
|
||||||
|
| 子命令 / 开关 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `pure-note serve` | 启动;`--addr :8080`、`--data-dir ./data`、`--log-level`、`--log-format text|json`、`--behind-proxy`(声明位于可信反代之后,启用 XFF 处理)、`--dev`(loopback-only,§7.3-8)、`--allow-newer`(跳过 schema 版本上界校验,§10.4);环境变量:`PN_ADMIN_PASSWORD` |
|
||||||
|
| `pure-note init` | 首次初始化:设口令(Argon2id+PHC)、站点标题 |
|
||||||
|
| `pure-note backup [path]` | 在线备份:`VACUUM INTO`(一致快照,不停服);默认输出 0600 权限 |
|
||||||
|
| `pure-note gc` | 回收站过期清除 + 孤儿图片清除 + 会话清理;**默认 `--dry-run`**,`--commit` 才执行(§6.3) |
|
||||||
|
| `pure-note version` | 版本号(build 时注入 commit/时间) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 前端设计
|
||||||
|
|
||||||
|
### 8.1 页面与路由
|
||||||
|
|
||||||
|
| 路由 | 页面 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/` | 博客首页 | 公开笔记列表(置顶优先、分页)、标签云、站点标题 |
|
||||||
|
| `/notes/:slug` | 笔记详情 | Markdown 渲染、面包屑、上一篇/下一篇;响应 `status=private` 且为管理员时显示「私有预览」横幅 |
|
||||||
|
| `/tags/:tag` | 标签页 | 该标签下公开笔记 |
|
||||||
|
| `/admin/login` | 登录页 | 口令登录、防爆破提示 |
|
||||||
|
| `/admin` | 管理列表 | 全部笔记(公/私标签可见)、状态开关、新建、删除(入回收站) |
|
||||||
|
| `/admin/trash` | 回收站 | 已删列表、恢复 |
|
||||||
|
| `/admin/notes/new` `/admin/notes/:id/edit` | 编辑器 | CodeMirror 源码编辑 + 实时预览(分屏/切换)、元信息侧栏(标题/slug/标签/摘要/公开开关/置顶)、图片粘贴上传 |
|
||||||
|
| `*` | 404 | 统一兜底 |
|
||||||
|
|
||||||
|
### 8.2 核心技术要点
|
||||||
|
|
||||||
|
**Markdown 渲染管线(浏览态)**
|
||||||
|
|
||||||
|
```
|
||||||
|
Markdown 原文
|
||||||
|
→ react-markdown 10 (remark-gfm: 表格/任务列表/删除线/自动链接)
|
||||||
|
→ rehype-sanitize 6(白名单 schema:禁 script/iframe/事件属性/未知协议/style 属性,
|
||||||
|
允许任务列表 checkbox 所需 input[type=checkbox disabled checked],
|
||||||
|
加 class 白名单供高亮使用,链接强制 target=_blank + rel="nofollow noopener noreferrer")
|
||||||
|
→ rehype-highlight 7(纯 class 高亮,主题 CSS 静态打包,零内联样式)
|
||||||
|
→ 渲染(@tailwindcss/typography prose 排版 + 自定义 CodeBlock 组件带复制按钮)
|
||||||
|
```
|
||||||
|
|
||||||
|
两处防线:链路内的 rehype-sanitize,加上服务端 goldmark 转义与 CSP `script-src 'self'`,即使清洗层被绕过也无法执行脚本。
|
||||||
|
|
||||||
|
**编辑器(编辑态)**
|
||||||
|
|
||||||
|
- CodeMirror 6 + `@codemirror/lang-markdown`,`minimalSetup` + 行号 + 等宽字体;工具栏提供加粗/斜体/链接/代码/表格等 Markdown 辅助插入;
|
||||||
|
- **图片粘贴/拖拽**:自写 paste/drop handler(≤50 行)→ `POST /api/admin/images` → 光标处插入 ``;
|
||||||
|
- **slug 策略**:首次保存时由标题自动生成——中文标题退化为 `post-<YYYYMMDD>`,与现有记录(含回收站)冲突时追加 `-2`/`-3` 直至唯一;**首次保存即定稿,此后除非用户手动修改否则永不变化**;用户手改与他人冲突 → 服务端 409 + 字段级错误提示,前端就地高亮;
|
||||||
|
- 自动保存:防抖 2s;大改动前本地兜底(失败时保留编辑态不丢内容)。
|
||||||
|
|
||||||
|
**数据与状态**
|
||||||
|
|
||||||
|
- TanStack Query 管理全部服务端状态(列表/详情缓存 key 按 status 上下文隔离);
|
||||||
|
- 会话状态 `useAuth`(Context + `/api/me`);**CSRF token 仅存内存,刷新后经 `/api/me` 重取,禁止 localStorage**;
|
||||||
|
- 登录态失效(401)统一跳转 `/admin/login`。
|
||||||
|
|
||||||
|
**暗色主题**
|
||||||
|
|
||||||
|
Tailwind v4 `@custom-variant dark` + 根元素 class 切换 + localStorage 持久化 + 跟随系统选项。
|
||||||
|
|
||||||
|
### 8.3 构建与嵌入约定
|
||||||
|
|
||||||
|
1. **产物位置与 embed 机制**(审评 P0-2 修正):`go:embed` 不能跨包引用,构建链为:Vite 输出 `web/dist` → Makefile `sync-assets` 目标拷贝至 `internal/webui/dist` → `go:embed dist/*` + `fs.Sub` 在 `internal/webui` 包内嵌入;`internal/webui/dist` 进 .gitignore(保留 `.gitkeep` 占位供 M0 编译);
|
||||||
|
2. **Vite base**(审评 P0-1 修正):部署在域名根,使用**默认 `base: '/'`**。⚠️ 相对 base(`'./'`)会在 `/notes/:slug` 等深链下把资源解析到 `/notes/assets/…` 导致白屏,**禁止使用**;embed handler 从站点根提供 FS,绝对路径恰好正确命中;
|
||||||
|
3. **SPA fallback**:`/api/*` 之外命中文件则返回,未命中(SPA 路由)回退 `index.html`;
|
||||||
|
4. **元信息注入**(审评 P0-4 修正):请求 `/notes/{slug}` 时服务端查询该笔记,**仅当可见(`public` 或管理员会话)**才注入 `<title>/<meta description>/<meta og:*>`,否则回退站点默认 meta;注入经 `html/template` 自动转义,防标题内容打断标签结构;
|
||||||
|
5. **缓存与指纹**:构建产物内容 hash 文件名 + 长缓存头(嵌入二进制,指纹天然隔离新旧版本);
|
||||||
|
6. **冒烟测试守门**(进 CI):真实构建 → 启动二进制 → 请求任一公开 slug 页面 → 断言 script/link 以 200 + 正确 MIME 加载(防「深链白屏」类回归)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 安全设计
|
||||||
|
|
||||||
|
### 9.1 威胁模型与防护矩阵
|
||||||
|
|
||||||
|
| # | 威胁 | 防护措施 | 纵深 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| T1 | 存储型 XSS(恶意 Markdown/HTML/链接) | goldmark 默认转义(服务端渲染路径);渲染链路 rehype-sanitize 白名单(schema 禁 style 属性);链接协议白名单;CSP `script-src 'self'` 兜底 | 三层 |
|
||||||
|
| T2 | CSRF | 双保险:CSRF token 头校验 + **全 POST 覆盖(含 /api/auth/*)**的 Origin/Referer→Host 校验 + SameSite=Lax;login 的 Origin 校验同时防「跨站表单预热消耗令牌桶把管理员锁在门外」的 DoS 放大 | 三重 |
|
||||||
|
| T3 | 会话劫持/固定 | crypto/rand 256bit token;DB 存哈希;`__Host-` Secure HttpOnly Cookie;登录重建会话;登出删行;7 天过期 + 滑动续期(轮换保持 csrf 不变) | — |
|
||||||
|
| T4 | 口令爆破 | Argon2id(OWASP 参数);per-IP + per-账号令牌桶限流(fail-only);常量时间比较;统一 401 文案;改密复用同一套限流 | — |
|
||||||
|
| T5 | 恶意上传(多态/WebShell) | MIME 白名单 + 魔数校验 + 拒绝 SVG;BLOB 入库存不进文件系统(无执行环境);禁用路径穿越;nosniff;CSP `img-src` 限制;≤5MB | — |
|
||||||
|
| T6 | SQL 注入 | 全部参数化查询;无拼接;无 ORM 动态链 | — |
|
||||||
|
| T7 | 路径穿越/静态文件越界 | 仅从 embed FS 提供文件;无用户可控路径参数 | — |
|
||||||
|
| T8 | 点击劫持 | `Content-Security-Policy: frame-ancestors 'none'` + `X-Frame-Options: DENY` | — |
|
||||||
|
| T9 | MIME 嗅探 | `X-Content-Type-Options: nosniff` 全局 | — |
|
||||||
|
| T10 | 私密笔记越权泄露 | 可见性在**服务端查询层**统一过滤(`status='public' AND deleted_at IS NULL`,覆盖列表/详情/标签/RSS/sitemap/meta 注入/图片直链);图片按并集语义判定;前端标识仅供参考。**已知边界(显式接受)**:图片一旦随公开笔记暴露,已分发副本(代理缓存/爬虫/RSS 阅读器)无法撤回 | 单一可信点 |
|
||||||
|
| T11 | 传输层窃听 | 仅 HTTPS(Caddy 自动证书);`Strict-Transport-Security`(§9.2) | — |
|
||||||
|
| T12 | 依赖供应链漏洞 | Go 依赖面 5 个直接依赖 + govulncheck CI;npm 锁文件 + `npm audit`;定期 dependabot/renovate | — |
|
||||||
|
| T13 | 数据丢失 | WAL;每日 `VACUUM INTO` 备份(0600 权限)+ 异地加密(rclone crypt/age);回收站 30 天缓冲;备份保留期内数据物理仍在(语义已声明,§10.3) | — |
|
||||||
|
|
||||||
|
### 9.2 安全 HTTP 头(SecurityHeaders 中间件)
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Security-Policy:
|
||||||
|
default-src 'self';
|
||||||
|
script-src 'self'; ← 严格禁 inline script
|
||||||
|
style-src 'self' 'unsafe-inline'; ← 原因仅一项:CodeMirror 经 style-mod 运行时注入
|
||||||
|
<style>(rehype-highlight 为纯 class,不注入样式,
|
||||||
|
不构成理由)。硬化路径(P2):Go 每响应下发 nonce
|
||||||
|
注入 CSP + 前端 EditorView.cspNonce 传入,
|
||||||
|
即可收紧为 style-src 'self' 'nonce-…'
|
||||||
|
img-src 'self' data:; ← 图片仅本站 + data(粘贴预览)
|
||||||
|
font-src 'self';
|
||||||
|
connect-src 'self';
|
||||||
|
object-src 'none'; base-uri 'none';
|
||||||
|
frame-ancestors 'none';
|
||||||
|
form-action 'self';
|
||||||
|
Strict-Transport-Security: max-age=31536000; includeSubDomains
|
||||||
|
Referrer-Policy: strict-origin-when-cross-origin
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
X-Frame-Options: DENY
|
||||||
|
Permissions-Policy: camera=(), microphone=(), geolocation=()
|
||||||
|
```
|
||||||
|
|
||||||
|
补充约定:`/api/admin/*` 与 `/api/auth/*` 响应统一 `Cache-Control: no-store`(防登出后 bfcache/返回键回看);公共路由(无编辑器页面)可单独下发更严格 CSP `style-src 'self'`。
|
||||||
|
|
||||||
|
### 9.3 上线前安全检查单
|
||||||
|
|
||||||
|
- [ ] go vet + `govulncheck ./...` 干净;`npm audit --production` 干净
|
||||||
|
- [ ] **可见性矩阵自动化用例全绿**(§13),并人工抽查:私有笔记在列表/详情/标签/RSS/sitemap/图片直链/HTML meta 全部出口匿名不可见
|
||||||
|
- [ ] 投递恶意 Markdown(`<script>`、`javascript:` 链接、事件属性、`<iframe>`、可疑 style)渲染无脚本执行
|
||||||
|
- [ ] 跨站伪造 POST(含 login/logout)被拒绝;Cookie 属性与 `__Host-` 前缀验证;**刷新页面后 CSRF token 经 `/api/me` 重取可用**
|
||||||
|
- [ ] 上传伪造扩展名的可执行文件/SVG/超限文件被拒
|
||||||
|
- [ ] 登录接口连续爆破触发 429;登录失败日志含 IP
|
||||||
|
- [ ] `/api/admin/settings` 响应**不含** admin_password_hash;PUT 无法写入非白名单键;改密流程通过
|
||||||
|
- [ ] 图片访问:私有与不存在统一 404;`curl -I` 对比公开图(immutable)与非公开图(no-store)缓存头
|
||||||
|
- [ ] 安全头逐项用 curl/浏览器 DevTools 确认(含 HSTS)
|
||||||
|
- [ ] 备份还原演练:停服 → 删除 `-wal`/`-shm` → 替换 db → 恢复成功;备份文件权限 0600
|
||||||
|
- [ ] 回收站恢复用例通过;`gc --dry-run` 输出审阅后 `--commit`,且不含 7 天内孤儿图片
|
||||||
|
- [ ] user_version 越界启动被拒(`--allow-newer` 显式放行)
|
||||||
|
- [ ] **构建冒烟测试**通过(§8.3-6)
|
||||||
|
- [ ] Caddy 仅开放 80/443,程序监听 127.0.0.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 部署与运维
|
||||||
|
|
||||||
|
### 10.1 构建
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
web: cd web && npm ci && npm run build
|
||||||
|
|
||||||
|
# go:embed 不能跨包引用,必须把产物拷进 internal/webui 包目录
|
||||||
|
sync-assets:
|
||||||
|
rm -rf internal/webui/dist && cp -r web/dist internal/webui/dist
|
||||||
|
|
||||||
|
build: web sync-assets
|
||||||
|
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
|
||||||
|
-o pure-note ./cmd/pure-note
|
||||||
|
|
||||||
|
linux: web sync-assets
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
|
||||||
|
-ldflags="-s -w" -o pure-note-linux-amd64 ./cmd/pure-note
|
||||||
|
|
||||||
|
dev: # 双端热重载:air(Go)+ vite dev(proxy /api → 127.0.0.1:8080)+ serve --dev
|
||||||
|
air & cd web && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 运行
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# deploy/pure-note.service
|
||||||
|
[Unit]
|
||||||
|
Description=Pure Note
|
||||||
|
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
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/opt/pure-note/data
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
# deploy/Caddyfile
|
||||||
|
example.com {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy 127.0.0.1:8080
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 幂等启动:`serve` 时若 settings 无口令哈希则拒绝启动并提示先跑 `init`;`user_version` 越界同样拒绝(§10.4)。
|
||||||
|
|
||||||
|
### 10.3 备份与恢复
|
||||||
|
|
||||||
|
**调度(systemd timer,替代 root cron)**
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# deploy/pure-note-maint.timer
|
||||||
|
[Unit]
|
||||||
|
Description=Daily Pure Note maintenance (gc + backup)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=03:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
```
|
||||||
|
|
||||||
|
timer 触发 `pure-note-maint.service`:先 `pure-note gc --commit`,后 `pure-note backup /backup/pure-note-$(date +\%F).db`。
|
||||||
|
|
||||||
|
**要点**
|
||||||
|
|
||||||
|
- 备份进程独立于服务进程运行,不受 unit 沙箱约束;`VACUUM INTO` 在 WAL 下与 serve 并发安全(在线一致快照);
|
||||||
|
- `/backup` 目录属主 `purenote`、权限 0700;备份文件**chmod 0600**(内含全部私有笔记、图片、口令哈希、会话哈希的明文副本);
|
||||||
|
- 保留 30 天;**语义声明**:备份保留期内被删除的数据物理上仍存在于备份中;
|
||||||
|
- 异地同步(可选):`rclone` 远端启用 **crypt**(或先 `age` 加密再上传),禁止明文上云;
|
||||||
|
- **恢复 SOP**:停服 → 删除 `data/pure-note.db-wal` 与 `-shm`(残留会污染还原)→ 以备份文件替换 `pure-note.db` → 起服。
|
||||||
|
|
||||||
|
### 10.4 升级
|
||||||
|
|
||||||
|
1. **升级前第一步固定为 `pure-note backup`**(写入 SOP);
|
||||||
|
2. 停服 → 替换二进制 → 起服触发迁移:迁移仅追加式(不删列/重命名/改类型),`user_version` 顺序递增;
|
||||||
|
3. **启动守卫**:代码校验 `user_version ≤ 本版本支持的最高版本`,超出**拒绝启动**并提示,`--allow-newer` 显式放行(防止旧二进制静默在陌生 schema 上读写);
|
||||||
|
4. 回滚:若迁移尚未执行(版本一致)可直接换回旧二进制;若已执行且不满足追加式约束,回滚 = 从步骤 1 的备份还原数据文件后再换回旧二进制。
|
||||||
|
|
||||||
|
### 10.5 观测性与开发工作流
|
||||||
|
|
||||||
|
- **日志**:标准库 `log/slog`;`--log-format text`(journald 友好)或 `json`;登录失败记 IP 与桶剩余计数;管理操作(增删改)记审计条目;不做纯请求量日志;
|
||||||
|
- **优雅停机**:SIGTERM/SIGINT → `http.Server.Shutdown`(10s 排空)→ SQLite 连接关闭;systemd `ExecStop` 无需特殊配置;
|
||||||
|
- **本地开发**:`make dev` 双进程——Go 侧 air 热重载,前端 `vite dev` 配置 `server.proxy` 把 `/api` 代理到 `127.0.0.1:8080`(**同源是 cookie/CSRF 成立的前提**);Go 侧 `--dev` 放开非 Secure Cookie(loopback-only 守卫)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 性能与规模边界
|
||||||
|
|
||||||
|
| 维度 | 边界与说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 笔记量 | 1 万篇内无压力;列表查询走 `idx_notes_public` 复合索引 |
|
||||||
|
| 图片量 | 总 BLOB < 2GB 从容;单图 ≤5MB;公开图 `immutable` 缓存使图片仅首次请求走 DB |
|
||||||
|
| 并发 | `SetMaxOpenConns(1)` 串行写 + WAL;个人博客日流量千级 PV 足够;瓶颈先出现在带宽而非后端 |
|
||||||
|
| 多实例 | **不支持**(内存限流、单库设计);需要时迁移 scs + 共享存储,属新架构决策 |
|
||||||
|
| 限流器内存 | per-IP 桶上限 4096 条 + TTL 逐出(防伪造 XFF 撑爆内存,§14) |
|
||||||
|
| SQLite 通用边界 | 单机单进程;不适用于写入密集型多写者场景(本项目读多写极少,完美契合) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 开发路线图
|
||||||
|
|
||||||
|
| 里程碑 | 内容 | 测试/验收门 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| M0 骨架 | Go 模块 + SQLite 迁移(user_version 守卫)+ 配置子命令 + 中间件链 + embed 构链(拷贝 + 占位 dist)+ `make build`/`make dev` | `make build` 产出可运行二进制;`serve` 起服务;`/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 全项通过 |
|
||||||
|
| M4 运维 | systemd(服务 + maint timer)、升级演练、hey 压测、备份恢复演练 | 按 §10.3 恢复 SOP 演练一次真还原 |
|
||||||
|
|
||||||
|
预估工作量:单人全职约 2–3 周(含安全项验证)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 测试策略
|
||||||
|
|
||||||
|
**核心不变量 = 可见性/越权矩阵**,表驱动自动化,随功能交付(M2 起)并长期进 CI:
|
||||||
|
|
||||||
|
| 维度 | 取值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 主体 | `{匿名, 管理员会话}` |
|
||||||
|
| 笔记状态 | `{public, private, trashed(回收站中), 不存在}` |
|
||||||
|
| 出口 | `{列表 /api/notes, 详情 /api/notes/{slug}, 标签 /api/tags, 图片 /api/images/{id}, RSS /feed.xml, sitemap, HTML meta 注入, 管理接口权限}` |
|
||||||
|
|
||||||
|
判定规则(全部断言为「匿名不可见 ⇒ 404 或输出中不含」):
|
||||||
|
|
||||||
|
- 匿名:仅 `public`(且未删除)可见,其余一律 404/不含;图片按并集语义;
|
||||||
|
- 管理员:全部正常笔记可见;回收站仅经 `/api/admin/trash`;
|
||||||
|
- 图片缓存头:公开图 immutable / 非公开 no-store / 未授权与不存在统一 404。
|
||||||
|
|
||||||
|
**其他测试组**(Go 集成测试:httptest + 临时目录真实 SQLite):
|
||||||
|
|
||||||
|
| 组 | 用例要点 |
|
||||||
|
| --- | --- |
|
||||||
|
| 迁移 | 空库 → 最新版本幂等;user_version 越界拒绝启动(`--allow-newer` 放行) |
|
||||||
|
| 认证会话 | 登录成功/失败(401 统一文案)、限流 429、改密(旧错拒绝/新弱拒绝/成功)、轮换后 CSRF 不变、`/api/me` 匿名与认证态 |
|
||||||
|
| CSRF | 无 token 403、错 token 403、跨源 Origin 拒绝(含 login/logout) |
|
||||||
|
| 上传 | 魔数不符/伪造扩展/SVG/超限/超时大 body 各拒绝;去重命中 |
|
||||||
|
| 回收站 | 删除→列表不可见→恢复→可见;30 天过期被 gc 清除;恢复后 refs 完整 |
|
||||||
|
| gc | dry-run 不改数据;7 天内孤儿图片不被删;会话过期清理 |
|
||||||
|
| slug | 同日冲突自动后缀;手改冲突 409 |
|
||||||
|
| 元信息注入 | 公开 slug 注入该笔记 meta;私有 slug(匿名)回退站点默认 meta |
|
||||||
|
|
||||||
|
前端测试(vitest):sanitize schema 快照(禁 style/script/iframe、允许任务列表 checkbox)、渲染管线对恶意 Markdown 的冒烟用例。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 风险与备选方案
|
||||||
|
|
||||||
|
| 风险 / 权衡 | 应对 / 备选 |
|
||||||
|
| --- | --- |
|
||||||
|
| 图片「已公开即视为已分发」,转私无法撤回已泄露副本 | 已在 §6.2/§9.1 声明为接受边界;私密图片请勿与公开笔记共用同一文件(去重会合并)——或在编辑器内提示 |
|
||||||
|
| SPA 对搜索引擎弱 | 已建 RSS + sitemap + 服务端 meta 注入(带可见性规则);公开页 Go 模板 SSR 是 v2 备选(保留 API 纯净性,切换成本可控) |
|
||||||
|
| 软删除占用 slug 最多 30 天(保恢复身份) | 可接受:个人站点频道创建频率低;如需立即释放,先在回收站手动「立即清除」(v1.1+ 可选) |
|
||||||
|
| goldmark v1 与 v2 分叉 | 初始锁定 v1.8.x(成熟);v2.0.1 稳定后评估一次性升级 |
|
||||||
|
| TypeScript 7 生态兼容面 | 出现第三方库不兼容即回退 TS 5.9,无架构影响 |
|
||||||
|
| 编辑器路线摇摆 | `MarkdownViewer` 组件边界独立;若日后要 WYSIWYG,仅替换 Editor(Tiptap 3 备选),数据层不变 |
|
||||||
|
| SQLite BLOB 膨胀 | 去重 + 上传上限 + gc 清孤儿;失控时图片降级为「文件系统存储 + DB 存路径」(预留 store 接口) |
|
||||||
|
| 自建会话的实现风险 | 实现面小(单表 + 标准 crypto,见 §13 测试组),并有 scs v2.9 作备选平替 |
|
||||||
|
| 反代之后限流 IP 识别 | 唯一可信代理 = Caddy(`--behind-proxy` 声明);取 `X-Forwarded-For` **最右**一个条目(Caddy 追加),或让 Caddy 覆盖 XFF;桶有上限与 TTL |
|
||||||
|
| CSP `style-src 'unsafe-inline'` 残余面 | v1 保留但注释修正;硬化路径:Go 每响应 nonce + `EditorView.cspNonce` 收紧为 `'nonce-…'`(§9.2),列为 M3+ 待办 |
|
||||||
|
| 构建链(嵌入/深链白屏)回归 | §8.3-6 冒烟测试进 CI,从 M1 起持续守护 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 A:依赖清单汇总(2026-09 核实)
|
||||||
|
|
||||||
|
**Go(直接依赖,共 5)**
|
||||||
|
|
||||||
|
| 依赖 | 版本 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| modernc.org/sqlite | v1.58.0 | 纯 Go SQLite 驱动(无 CGO) |
|
||||||
|
| github.com/yuin/goldmark | v1.8.6 | 服务端 Markdown 渲染(RSS/meta) |
|
||||||
|
| github.com/microcosm-cc/bluemonday | v1.0.27 | 服务端 HTML 白名单清洗 |
|
||||||
|
| golang.org/x/crypto | v0.56.0 | Argon2id |
|
||||||
|
| golang.org/x/time | v0.15.0 | 登录/改密限流 |
|
||||||
|
|
||||||
|
**前端(核心,devDependencies 另计)**
|
||||||
|
|
||||||
|
| 依赖 | 版本 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| react / react-dom | 19.2.8 | UI 框架 |
|
||||||
|
| vite / @vitejs/plugin-react | 8.2.2 | 构建(base 默认 `'/'`) |
|
||||||
|
| tailwindcss + @tailwindcss/vite + @tailwindcss/typography | 4.3.3 | 样式体系 |
|
||||||
|
| shadcn/ui(CLI)+ Radix primitives | 4.21.0 | 组件库 |
|
||||||
|
| react-router | 8.3.1 | 路由 |
|
||||||
|
| @tanstack/react-query | 5.102.8 | 服务端状态 |
|
||||||
|
| react-markdown + remark-gfm + rehype-sanitize + rehype-highlight | 10.1.0 / 4.0.1 / 6.0.0 / 7.0.2 | Markdown 渲染与防 XSS |
|
||||||
|
| @uiw/react-codemirror + @codemirror/lang-markdown | 4.25.11 / 6.5.2 | 编辑器 |
|
||||||
|
| lucide-react | 1.42.0 | 图标 |
|
||||||
|
|
||||||
|
**开发工具(建议)**:air(Go 热重载);vitest(前端测试)
|
||||||
|
|
||||||
|
**主要参考链接**
|
||||||
|
|
||||||
|
- https://go.dev/dl/ · https://pkg.go.dev/modernc.org/sqlite
|
||||||
|
- https://github.com/yuin/goldmark · https://github.com/microcosm-cc/bluemonday
|
||||||
|
- https://github.com/facebook/react · https://github.com/vitejs/vite
|
||||||
|
- https://github.com/shadcn-ui/ui · https://github.com/remix-run/react-router
|
||||||
|
- https://github.com/remarkjs/react-markdown · https://github.com/rehypejs/rehype-sanitize
|
||||||
|
- https://github.com/ueberdosis/tiptap · https://github.com/uiwjs/react-codemirror
|
||||||
|
|
||||||
|
> 实际开发时以 `go mod tidy` / `npm install` 解析到的版本为准,锁定文件(go.sum / package-lock.json)入库。
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# 《Pure Note 设计方案 v1.0》审评报告(第 1 轮)
|
||||||
|
|
||||||
|
> 审评对象:docs/design.md(v1.0,2026-09-07)
|
||||||
|
> 审评日期:2026-09-07
|
||||||
|
> 审评方法:作者自查 + 两路独立交叉评审(安全攻防视角 / 工程可行性视角),关键争议点在线核实(Vite 官方文档、rehype-highlight README、@codemirror/view 与 style-mod 源码)。所有发现均已逐条对照原文验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、总体结论
|
||||||
|
|
||||||
|
**方案骨架成立,选型与架构方向无需推翻;但按现稿直接开工会在 4 个 P0 问题上翻车**(2 个功能性硬伤、2 个私密内容泄露面),契约层还埋有 11 个 P1 雷。修订后可进入 M0。
|
||||||
|
|
||||||
|
| 级别 | 定义 | 数量 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| P0 | 按文档字面实现即失败或泄露私密内容,实现前必须修订 | **4** |
|
||||||
|
| P1 | 重要缺陷/缺口,不修会在对应模块返工或留下安全弱点 | **11** |
|
||||||
|
| P2 | 加固与工程化改进 | **9** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、发现汇总
|
||||||
|
|
||||||
|
| # | 级别 | 位置 | 问题一句话 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | P0 | §8.3 | Vite `base: './'` 与 SPA 深链路由冲突,二级路径全部白屏 |
|
||||||
|
| 2 | P0 | §5 / §10.1 | go:embed 跨包引用不可实现,构建链缺 dist 同步步骤,静默嵌入陈旧前端 |
|
||||||
|
| 3 | P0 | §7.4 | 私有图片响应 `Cache-Control: public, immutable` 授权共享缓存存储私密内容 |
|
||||||
|
| 4 | P0 | §8.3 | index.html 元信息注入未做可见性过滤,私有笔记标题/摘要泄露给匿名爬虫 |
|
||||||
|
| 5 | P1 | §7.3 / §8.2 | CSRF token 生命周期断裂:刷新后无法再获取、轮换语义未定义 |
|
||||||
|
| 6 | P1 | §7.1 | 管理员预览私有笔记的数据通路不存在(公开详情 404,管理接口只有编辑态) |
|
||||||
|
| 7 | P1 | §7.1 / §6.1 | settings 接口无字段白名单:口令哈希可能被回显/覆写;且无改密流程 |
|
||||||
|
| 8 | P1 | §7.1 | `/api/tags` 未写公开过滤,私有笔记的标签成为信息泄露面 |
|
||||||
|
| 9 | P1 | §7.1 / §8.2 | slug 自动生成不满足「唯一」承诺(同日必冲突),定稿时机未定义 |
|
||||||
|
| 10 | P1 | §6.2 / §7.6 | gc 无宽限期:编辑中已上传未保存的图片会被物理删除 |
|
||||||
|
| 11 | P1 | §7.1 | 笔记硬删除无回收站,误删当天内容不可恢复 |
|
||||||
|
| 12 | P1 | §7.2 | JSON 接口无请求体大小限制、服务器无超时(匿名单请求 OOM / slowloris) |
|
||||||
|
| 13 | P1 | §10.4 | 升级回滚说法不严谨:旧二进制遇更高 user_version 会静默在陌生 schema 上读写 |
|
||||||
|
| 14 | P1 | §12 / §9.3 | 测试策略整体缺失,可见性/越权矩阵无自动化回归 |
|
||||||
|
| 15 | P1 | §10.3 | 备份文件是全量明文私密数据:权限、加密、恢复细节未设防 |
|
||||||
|
| 16 | P2 | §7.1 / §9.3 | 图片未授权应统一 404(防 ID 枚举),文档未明确 404 vs 403 |
|
||||||
|
| 17 | P2 | §9.2 / §10.2 | HSTS 在威胁矩阵里声称了,但安全头清单和 Caddyfile 都没有 |
|
||||||
|
| 18 | P2 | §9.2 | `style-src 'unsafe-inline'` 的理由一半错误;nonce 方案可达严格 CSP |
|
||||||
|
| 19 | P2 | §7.2 | `/api/auth/*` 不在 Origin 校验分支(login CSRF + 令牌桶 DoS 放大) |
|
||||||
|
| 20 | P2 | §13 | 「XFF 可信首跳」表述含混(首/尾跳取错则限流整体失效) |
|
||||||
|
| 21 | P2 | §7.3 | Argon2id 未规定 PHC 编码存储(参数升级将无法校验旧哈希) |
|
||||||
|
| 22 | P2 | §7.1 | 分页参数无边界约定 |
|
||||||
|
| 23 | P2 | §10.3 | cron 备份与 systemd 沙箱的关系未声明(结论:不冲突,但需写明前提) |
|
||||||
|
| 24 | P2 | §10 / §12 | 优雅停机、日志选型(slog)、开发热重载工作流三处缺口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、P0 详细说明
|
||||||
|
|
||||||
|
### P0-1 Vite `base: './'` 与 SPA 深链冲突(§8.3)
|
||||||
|
|
||||||
|
**问题**:文档规定 `base: './'`(来自第一轮调研结论「绝对路径会 404」——**该论断不成立,本轮已用 Vite 官方文档推翻**)。相对 base 适用于「部署路径事先未知」或 file:// 嵌入场景;本项目部署在域名根,恰恰是反面用例。
|
||||||
|
|
||||||
|
**推导**:`base: './'` 时 index.html 资源引用为 `./assets/index-HASH.js`。浏览器深链访问 `/notes/my-post`(SPA fallback 返回 index.html,地址栏不变),按 RFC 3986 相对解析:`/notes/my-post` 的目录为 `/notes/`,故解析为 `/notes/assets/index-HASH.js` → 未命中 embed FS → fallback 返回 200 + `text/html` → module script 因 MIME 校验拒绝执行 → **React 永不启动,白屏**。
|
||||||
|
|
||||||
|
**波及范围**:`/notes/:slug`、`/tags/:tag`、`/admin/login`、`/admin/notes/:id/edit` 等所有二级路径的刷新、深链、外链分享。且 `vite dev` 与客户端导航均正常,**只在嵌套路由初始加载时爆发**,测试不充分必然漏网。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- `vite.config.ts` 使用默认 `base: '/'`;embed handler 从站点根提供 FS,绝对路径恰好正确命中;
|
||||||
|
- 删除文档中「必须 `base: './'`」及「绝对 /assets/ 会 404」的表述;
|
||||||
|
- CI 增加冒烟测试:真实构建 → 启动二进制 → 请求任一公开 slug 页面 → 断言 script/link 以 200 + 正确 MIME 加载。
|
||||||
|
|
||||||
|
### P0-2 go:embed 跨包引用不可实现(§5 / §8.3 / §10.1)
|
||||||
|
|
||||||
|
**问题**:构建产物输出在 `web/dist`,嵌入声明却在 `internal/webui` 包。`//go:embed` 只能嵌入本包目录树内的文件(不允许 `..`、不能跨包)。Makefile 的 `web` 目标止步于 `npm run build`,没有把 `web/dist` 同步进 `internal/webui/dist` 的步骤;M0 又要求「embed 空 dist」——占位文件先入库后,**编译永远不报错,二进制里嵌的是陈旧前端**,是典型「我明明改了前端」返工源。
|
||||||
|
|
||||||
|
**修复**(二选一,写入 §8.3):
|
||||||
|
- (a) Makefile 增加 `cp -r web/dist/ internal/webui/dist/`;`internal/webui/dist` 进 .gitignore,仓库保留占位文件供 M0 编译;
|
||||||
|
- (b) embed 上提到仓库根包:`//go:embed all:web/dist` + `fs.Sub`(无需拷贝,但根包与前端目录耦合)。
|
||||||
|
|
||||||
|
### P0-3 私有图片缓存头授权共享缓存存储(§7.4)
|
||||||
|
|
||||||
|
**问题**:文档对所有图片一刀切 `Cache-Control: public, max-age=31536000, immutable`。`public` 指令(RFC 9111)明确授权**共享缓存**存储该响应——即使是对带凭据请求的答复。
|
||||||
|
|
||||||
|
**攻击链**:管理员在 TLS 拦截代理环境浏览私有笔记 → 代理缓存 `GET /api/images/47` → 同网络任何人请求同 URL 直接命中缓存,**不回源、绕过鉴权与日志**;`images.id` 自增,`/api/images/1..N` 可批量收割。另有两条独立伤害:笔记 public→private 切换后,已缓存副本因 `immutable` 一年内不再回源,违反 F2「切换即时生效」;登出后磁盘缓存仍可离线回放。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- 按可见性分流:公开图 `public, max-age=31536000, immutable`;非公开图 `private, no-store`(或 `private, max-age=0, must-revalidate`,且再验证必须重新执行可见性判定而非仅比 ETag);
|
||||||
|
- `/api/admin/*` JSON 响应统一 `Cache-Control: no-store`(防登出后 bfcache/返回键回看私有列表);
|
||||||
|
- 文档显式承认残余边界:「图片一旦随公开笔记暴露过,已分发副本(浏览器缓存/爬虫/RSS 阅读器)无法撤回」。
|
||||||
|
|
||||||
|
### P0-4 元信息注入泄露私有笔记(§8.3)
|
||||||
|
|
||||||
|
**问题**:§8.3 要求「请求 /notes/{slug} 时注入该笔记的 title/description/og meta」,但**没有写可见性过滤条件**;T10 的「单一可信点」也没把这条路纳入。叠加「非 API 路径一律 200 fallback」与默认 slug `post-YYYYMMDD` 的可预测性:攻击者逐个请求候选 slug,即使 SPA 渲染 404,**HTML 源码里已带私有笔记的标题与 og:description**。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- 注入查询与公开详情接口走同一条 `status='public'`(或管理员会话)规则,明确写入文档;
|
||||||
|
- 非公开一律回退站点默认 meta;
|
||||||
|
- 注入用 `html/template` 自动转义(或占位符替换),防标题内容打断标签结构注入任意 meta。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、P1 详细说明
|
||||||
|
|
||||||
|
### P1-5 CSRF token 生命周期断裂(§7.3 / §8.2)
|
||||||
|
|
||||||
|
只在登录响应下发一次、仅存内存,`/api/me` 只回 `{authenticated}`。浏览器 F5 后会话 cookie 仍有效但 CSRF token 丢失 → 全部变更请求 403,实现者最可能的「自救」是把 token 落 localStorage(降级)或放宽校验(更糟),两者都是文档逼出来的。「距过期 <3 天旋转 token」也未规定 csrf_token 是否随新会话行重建。
|
||||||
|
**修复**:`GET /api/me` 在已认证时返回 csrf_token(同源 JSON,跨域读不到);会话轮换时保持 csrf_token 不变或在轮换响应中同步下发;明确禁止持久化;补充 dev 方案(`__Host-` 要求 Secure,`http://局域网IP` 登不上——dev 放宽开关必须仅 loopback 监听时生效)。
|
||||||
|
|
||||||
|
### P1-6 管理员预览私有笔记的通路缺失(§7.1)
|
||||||
|
|
||||||
|
公开 `GET /api/notes/{slug}` 对私有一律 404,管理接口只有按数字 id 的编辑态数据——前端 Note 页没有任何能取到私有笔记正文的数据源,「博客皮肤预览私有笔记」功能不存在。
|
||||||
|
**修复**:详情接口可见性规则定义为 `status='public' OR 有效管理员会话`(响应带 `status` 字段,前端显示「私有预览」横幅);meta 注入同规则。
|
||||||
|
|
||||||
|
### P1-7 settings 无字段白名单,口令哈希面临回显与覆写(§7.1 / §6.1)
|
||||||
|
|
||||||
|
`admin_password_hash` 与站点配置混存 settings 表;`GET/PUT /api/admin/settings` 未约定 DTO。朴素实现 `SELECT *` 即把 Argon2id 哈希下发浏览器(任何 XSS 即外泄凭证验证器);PUT 若接受任意 key,可携管理员会话直接覆写哈希完成「免旧密码改密」。全文也没有 init 之外的改密流程。
|
||||||
|
**修复**:显式 SettingsDTO(仅 site_title/site_desc/page_size),读永不序列化哈希、写只收白名单键;新增 `POST /api/admin/password {old, new}`,复用登录限流。
|
||||||
|
|
||||||
|
### P1-8 `/api/tags` 未写公开过滤(§7.1 / §9.1)
|
||||||
|
|
||||||
|
T10 点名了 API/RSS/sitemap/图片,唯独漏了 tags;API 表里只写「标签聚合」。私有笔记的敏感标签(病情、辞职、项目名)可被 `/api/tags` 与候选词探测确认。
|
||||||
|
**修复**:tags 聚合 SQL 统一加 `status='public'`;标签页同源过滤;§9.3 检查单补 tags 断言。
|
||||||
|
|
||||||
|
### P1-9 slug 生成不满足「唯一」承诺(§7.1 / §8.2)
|
||||||
|
|
||||||
|
§7.1 承诺「自动生成唯一 slug」,§8.2 方案是日期式 `post-20260907`——同日第二篇必然 UNIQUE 冲突,而文档把解冲突推给用户(「给出明确错误」),自相矛盾。slug 何时定稿(新建即分配还是首次保存)、自动保存下改标题是否重生成,均未定义。
|
||||||
|
**修复**:自解冲突(追加 `-2`/`-3` 或 4 位随机尾缀);「slug 仅首次保存定稿,此后除非手改不变」;冲突返回 409 + 字段级错误。
|
||||||
|
|
||||||
|
### P1-10 gc 误删编辑中的图片(§6.2 / §7.6)
|
||||||
|
|
||||||
|
图片在粘贴瞬间即上传入库,笔记保存前是 0 引用孤儿;gc 策略只有「清理 0 引用」没有时间条件,调度也未定义——长文编辑期间跑一次 gc,刚粘贴的图片被物理删除,保存后满屏死图。另外没有任何列出孤儿图片的管理接口,「孤儿仅管理员可见」实际谁也看不见。
|
||||||
|
**修复**:gc 只删 `created_at < now − 7天` 且 0 引用的图片;默认 `--dry-run`、`--commit` 才执行;调度写明(systemd timer 每日);补 `GET /api/admin/images?orphan=1` 供检视。
|
||||||
|
|
||||||
|
### P1-11 硬删除无回收站(§7.1)
|
||||||
|
|
||||||
|
恢复路径只有「停服 + 整库回滚到 03:00 备份」:当天新建又误删的笔记不在任何备份里,永久丢失;即便有备份,恢复单篇要整库回滚,会连带丢其他笔记当天改动。同类产品惯例(Notion/Google Docs 回收站 30 天、Obsidian 移入系统回收站)——「个人笔记」恰是最不该裸删的数据。
|
||||||
|
**修复**:最低成本方案——DELETE 时把笔记 JSON(含 refs)写入 tombstone 表,gc 30 天后清除,管理端加「最近删除」恢复入口;或 `notes` 加 `deleted_at` 软删列 + 查询统一过滤。
|
||||||
|
|
||||||
|
### P1-12 请求体限制与服务器超时缺失(§7.2)
|
||||||
|
|
||||||
|
5MB 限制仅在上传接口。`POST /api/auth/login` 是匿名可打的 JSON 接口,无 `http.MaxBytesReader`,单次请求即可提交任意大 body(per-IP 限流挡不住「一个」大请求);`ReadHeaderTimeout/ReadTimeout/IdleTimeout` 未提及(slowloris 面)。
|
||||||
|
**修复**:路由级 `http.MaxBytesReader`(login/settings 4–64KB、notes JSON 1MB、multipart 6MB 含开销);`http.Server{ReadHeaderTimeout: 5s, ReadTimeout: 60s, IdleTimeout: 120s}`,写入 §7.2。
|
||||||
|
|
||||||
|
### P1-13 升级回滚不严谨(§10.4)
|
||||||
|
|
||||||
|
「回滚 = 换回旧二进制(迁移可逆或旧版本可忽略新表)」不成立:`user_version` 是单向整数,旧二进制看到更高版本**不会报错**,会静默在陌生 schema 上读写,把可恢复事故变成不可恢复。「向前兼容」一词也用反了。
|
||||||
|
**修复**:启动时校验 `user_version ≤ 代码支持的最高版本`,超出拒绝启动(留 `--allow-newer` 旗标);迁移策略声明为「仅追加式」(禁止删列/重命名/改类型);升级 SOP 第一步固定为 `pure-note backup`。
|
||||||
|
|
||||||
|
### P1-14 测试策略缺失(§12 / §9.3)
|
||||||
|
|
||||||
|
五个里程碑无一提及测试;§9.3 是一次性人工检查单。本项目安全性的核心不变量恰是可见性/越权矩阵——{public, private} × {匿名, 管理员} × {列表, 详情, 图片, RSS, sitemap, meta 注入, tags}——最适合表驱动自动化;后续任何重构(尤其 §13 预留的 v2 SSR 化)都可能无声破坏 T10。
|
||||||
|
**修复**:Go 集成测试(httptest + 临时目录真实 SQLite)把矩阵做成表驱动用例,另加迁移幂等、限流、CSRF、魔数校验各一组;纳入 CI,从 M2 起随功能交付。
|
||||||
|
|
||||||
|
### P1-15 备份文件敏感性与恢复 SOP(§10.3)
|
||||||
|
|
||||||
|
备份 = 全量私有笔记 + 图片 BLOB + 口令哈希 + 会话哈希的**明文副本**。crontab 若以 root 运行、umask 022,备份文件 0644,同机任意本地用户可读全部私密内容;rclone 异地同步也是明文。恢复步骤未要求删除残留 `-wal`/`-shm`(旧 WAL 会污染还原后的库);「备份保留 30 天」与「删除笔记」的数据语义关系未声明。
|
||||||
|
**修复**:备份文件 chmod 0600 / 专用低权用户;异地前强制加密(age / rclone crypt);恢复 SOP 补「删除 `pure-note.db-wal` 与 `-shm`」;声明备份保留与删除的关系。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、P2 简述
|
||||||
|
|
||||||
|
| # | 修复建议 |
|
||||||
|
| --- | --- |
|
||||||
|
| 16 | 图片未授权与不存在统一返回 404(与笔记 slug 一致,防自增 ID 枚举);§9.3 改为明确断言 + 遍历测试用例 |
|
||||||
|
| 17 | `Strict-Transport-Security: max-age=31536000` 写入 SecurityHeaders 中间件(经反代透传有效)或 Caddyfile,消除 T11 与 §9.2 的不一致 |
|
||||||
|
| 18 | 已核实:rehype-highlight **不注入任何样式**(只加 class),§9.2 把它列为 `unsafe-inline` 理由是错的;CodeMirror 经 style-mod 注入 `<style>` 属实,但 `@codemirror/view` 提供 `EditorView.cspNonce`——每响应由 Go 生成 nonce 注入 CSP + 前端传入,即可删掉 `'unsafe-inline'`。v1 可保守保留,但须修正注释并规定 rehype-sanitize schema 永不放开 `style` 属性;公共路由(无编辑器)可先单独下发严格 CSP |
|
||||||
|
| 19 | 所有 POST(含 `/api/auth/*`)统一 Origin/Referer → Host 校验:防 login CSRF,也堵「跨站表单无门槛消耗账号令牌桶(5 次/10 分钟)把真管理员锁在门外」的 DoS 放大 |
|
||||||
|
| 20 | 明确「唯一可信代理是 Caddy,取 XFF **最右**一个条目」(或让 Caddy 覆盖而非追加 XFF);per-IP 桶设数量上限 + TTL 清理,防伪造 IP 撑爆内存 |
|
||||||
|
| 21 | Argon2id 存 PHC 串 `$argon2id$v=19$m=19456,t=2,p=1$<b64salt>$<b64hash>`,参数随哈希走,未来调参可校验旧哈希 |
|
||||||
|
| 22 | page_size 夹取 [1,100](默认 10),page 夹取 [1,10⁴],越界 400 |
|
||||||
|
| 23 | 写明「备份以独立进程运行,不受 unit 沙箱约束;/backup 属主与权限要求」;建议 cron 改 systemd timer(`OnCalendar=03:00, Persistent=true`),日志进 journal |
|
||||||
|
| 24 | 补三件事:SIGTERM → `http.Server.Shutdown` + SQLite 干净关闭;日志选型 `log/slog`(text/JSON 双模式,登录失败记 IP 与计数);`make dev` 一键起双端(`vite dev` 配 `server.proxy` 转发 `/api` 到 127.0.0.1:8080——同源 cookie/CSRF 才成立,Go 侧 air 热重载) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、争议点裁定(本轮在线核实)
|
||||||
|
|
||||||
|
| 争议 | 裁定 | 依据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Vite `base: './'` vs `'/'` | **`'/'`(默认)**。第一轮调研结论「绝对路径会 404」不成立:embed handler 从站点根提供 FS,绝对路径恰好命中;相对 base 在深链下解析到错误目录 | Vite 官方文档(Public Base Path:relative base 适用于「路径事先未知」与嵌入场景)+ RFC 3986 相对解析推导 |
|
||||||
|
| `style-src 'unsafe-inline'` 是否必需 | 对 CodeMirror 成立(style-mod 注入 `<style>`),对 rehype-highlight 不成立(纯 class);且 nonce 方案可去掉 `unsafe-inline` | rehype-highlight README;style-mod v4.1.3 README(`StyleModule.mount` 支持 nonce);@codemirror/view `EditorView.cspNonce` facet 源码 |
|
||||||
|
| cron 备份与 systemd 沙箱是否冲突 | **不冲突**(沙箱只约束服务进程,管不到 crontab 拉起的独立进程;`VACUUM INTO` 在 WAL 下与 serve 并发安全),但前提需写明 | systemd 语义 + SQLite WAL 并发模型 |
|
||||||
|
| sha256 去重 + image_refs 并集语义 | 规则自洽但需文档化:同一图片字节被公开笔记引用即整体公开(可见性 = 所有引用的**并集**);「曾公开即视为已分发」须作为已知边界写入 | 设计内审 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、设计得当之处(维持不动)
|
||||||
|
|
||||||
|
1. 可见性收敛为服务端查询层单一可信点(T10),前端标识仅作展示——全文最值钱的决策;
|
||||||
|
2. 图片 BLOB 入库 + 魔数白名单不含 SVG——同时消灭 WebShell 执行面与 SVG 存储型 XSS 两类风险,且无路径穿越面;
|
||||||
|
3. 会话库存 SHA-256 摘要而非明文 token,备份/DB 泄露不直接等价会话劫持;
|
||||||
|
4. 只存 Markdown 原文、渲染期双层清洗 + CSP 兜底,安全策略升级可回溯覆盖全部存量笔记;
|
||||||
|
5. 高亮选 class-based 的 rehype-highlight 方向正确(已核实零内联样式);
|
||||||
|
6. WAL + busy_timeout + `VACUUM INTO` 在线快照 + `SetMaxOpenConns(1)`,与个人规模严格匹配;
|
||||||
|
7. ADR 记录、被否决备选、版本在线核实的文档纪律。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、v1.1 修订清单(对 design.md 的具体动作)
|
||||||
|
|
||||||
|
| 动作 | 涉及发现 |
|
||||||
|
| --- | --- |
|
||||||
|
| §8.3:`base` 改为默认 `'/'`,删除「必须 './'」及「绝对路径 404」论断;新增「构建冒烟测试」条目 | P0-1 |
|
||||||
|
| §5 / §10.1:确定 embed 机制(建议:Makefile 拷贝 dist → internal/webui/dist + .gitignore + 占位文件),Makefile 补拷贝步骤 | P0-2 |
|
||||||
|
| §7.4:图片缓存头按可见性分流;§7.2 或 §9.2:`/api/admin/*` 响应 `no-store`;§6.2 或 §9.1:写入并集可见性与「曾公开即已分发」边界 | P0-3、P1(语义) |
|
||||||
|
| §8.3:meta 注入可见性规则(public 或管理员会话,否则站点默认 meta)+ `html/template` 转义 | P0-4 |
|
||||||
|
| §7.1:`GET /api/me` 认证态返回 csrf_token;§7.3:轮换语义、禁 localStorage、dev cookie 方案 | P1-5 |
|
||||||
|
| §7.1:详情接口规则 `public OR 会话`,响应带 status;§8.1:「私有预览」横幅 | P1-6 |
|
||||||
|
| §7.1:SettingsDTO 白名单 + `POST /api/admin/password`;§6.1:PHC 编码 | P1-7、P2-21 |
|
||||||
|
| §7.1:`/api/tags` 明确 public 过滤;图片未授权统一 404 | P1-8、P2-16 |
|
||||||
|
| §7.1 / §8.2:slug 自解冲突算法 + 定稿规则 + 409 | P1-9 |
|
||||||
|
| §6.2 / §7.6:gc 宽限 7 天 + dry-run/commit + systemd timer + `GET /api/admin/images?orphan=1` | P1-10、P2-23(半) |
|
||||||
|
| §6 / §7.1:回收站(建议 tombstone 表,30 天,恢复入口) | P1-11 |
|
||||||
|
| §7.2:分级 MaxBytesReader + Server 超时;`/api/auth/*` 纳入 Origin 校验 | P1-12、P2-19 |
|
||||||
|
| §10.4:user_version 上界校验拒绝启动 + 仅追加式迁移 + 升级先备份 | P1-13 |
|
||||||
|
| 新增 §「测试策略」:可见性矩阵表驱动集成测试 + CI,纳入 M2–M4 验收 | P1-14 |
|
||||||
|
| §10.3:备份 0600 + 加密异地 + 恢复删 `-wal/-shm` + 保留语义;systemd timer 替代 cron | P1-15、P2-23 |
|
||||||
|
| §9.2:HSTS 补入;修正 `unsafe-inline` 注释,写明 nonce 升级路径与 sanitize schema 禁 `style` 属性 | P2-17、P2-18 |
|
||||||
|
| §13:XFF 改「最右一跳」+ 限流桶上限 | P2-20 |
|
||||||
|
| §7.1:分页边界 | P2-22 |
|
||||||
|
| §10 / §12:优雅停机、slog、`make dev`(vite proxy + air) | P2-24 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、结论
|
||||||
|
|
||||||
|
v1.0 的架构与选型判断经受住了对抗性审评(无一条要求推翻 ADR 级决策),但在「前端构建交付链」和「私密内容的缓存/元信息边界」上存在 4 个 P0 硬伤——其中 Vite base 一条源于第一轮调研结论本身的错误,属于**调研结论未复核即采纳**的流程教训。按第八节修订清单出 v1.1 后,方可进入 M0 开发。
|
||||||
Vendored
Reference in New Issue
Block a user