Files
pure-note/docs/design.md
T
wangairnan 5457aeaa33 feat: 新增发布日期自选、站点 Logo、回收站清空等十项改进
- 编辑页标题改 filled 变体;标签改 tags 选择器(可勾选既有/输入新建)
- 笔记发布日期可自选(schema v2 新增 published_at 并回填),前台展示发布日期
- 登录页移除「请输入管理密码以继续」「连续失败将被暂时锁定」文案
- 站点设置每页条数收敛为 10/20/30/50 选择器,后端白名单同源校验
- 站点设置两卡片宽屏左右/窄屏上下;修改密码按钮改常规大小
- 后台各页顶栏固定高度,切换页面不再抖动
- 站点 Logo:settings 新增 site_logo(站内路径),gc/孤儿清单豁免 Logo 图片,
  博客 header 站点名前展示,设置页支持上传/更换/清除
- 回收站右上角一键清空(DELETE /api/admin/trash,含确认弹窗)
- pn init 写入 Markdown 语法示例文档(slug welcome,公开)与程序生成的示例图片
2026-09-09 11:10:30 +08:00

730 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 | 站点设置 | 站点标题、副标题、每页条数(10/20/30/50)、站点 Logo(白名单字段,绝不含口令哈希) |
| 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 + **Ant Design 6** | 4.3.3 / 6.6.3 | Tailwind 负责页面排版与设计 token;交互组件统一用 antd(Button/Input/Table/Modal/Dropdown/Tooltip/notification 等),经 ConfigProvider 自定义主题对齐黑白灰品牌 token 并随三态主题切换明暗算法;v6 原生支持 React 19 | shadcn/ui + Radix(曾采用,组件需自维护,替换为 antd 降低长期维护成本)、Chakra 3、HeroUI |
| 路由 | 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 | 活跃、风格统一(antd 组件内亦复用) | — |
### 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)
▼
┌──────────────────────────┐
│ pn 单一二进制 (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/pn.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/
│ └── pn/
│ └── main.go # 入口 + 命令行(start/init/passwd/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、格式化
│ ├── theme/ # antd ConfigProvider 主题(品牌 token + 明暗算法)
│ ├── components/ # 业务组件
│ │ ├── markdown/ # MarkdownViewer(渲染管线,可替换)
│ │ └── editor/ # Editor(CodeMirror)+ ImagePaste + Toolbar
│ ├── hooks/ # use-toast(antd notification 封装)
│ ├── features/ # notes / admin 业务 hooks
│ └── pages/ # Home / Note / Tags / AdminLogin / AdminList / AdminTrash / AdminEdit
└── deploy/
├── pn.service # systemd 服务单元
├── pn-maint.timer # 每日 gc + 备份 timer
└── Caddyfile # 反代示例
```
---
## 6. 数据模型(SQLite)
### 6.1 Schema(DDL)
```sql
-- 连接串(DSN)统一注入以下 pragma:
-- file:data/pn.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,
published_at INTEGER -- 发布日期(可自选);v2 迁移回填为 created_at
);
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 / beian_no / site_logo(站内绝对路径)
-- 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 数据清理(`pn 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` | 回收站列表 |
| DELETE | `/api/admin/trash` | **清空回收站**:物理删除全部软删除笔记(refs 级联) |
| 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/beian_no/site_logo;page_size 仅 10/20/30/50),读永不序列化 `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. **初始化**:`pn 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. **口令重置(CLI)**:`pn passwd` 免旧口令直接覆盖哈希并**吊销全部会话**——前提是具备服务器访问权限(读写数据目录即可改库,CLI 提供规范入口优于手工改库);口令来源与 `init` 一致,避免出现在 argv;
9. **开发模式**:`--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 配置与命令行
| 子命令 / 开关 | 说明 |
| --- | --- |
| `pn start` | 启动;`--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` |
| `pn init` | 首次初始化:设口令(Argon2id+PHC)、站点标题;并写入一篇 Markdown 语法示例文档(公开,slug `welcome`)及程序生成的示例图片 |
| `pn passwd` | 重设管理员口令(覆盖旧哈希)并吊销全部会话;口令来源同 `init`(环境变量 `PN_ADMIN_PASSWORD` 或交互输入,不经 argv 避免 ps 泄露)。CLI 可达即具备服务器权限,允许直接重设 |
| `pn backup [path]` | 在线备份:`VACUUM INTO`(一致快照,不停服);默认输出 0600 权限 |
| `pn gc` | 回收站过期清除 + 孤儿图片清除 + 会话清理;**默认 `--dry-run`**,`--commit` 才执行(§6.3) |
| `pn version` | 版本号(build 时注入 commit/时间);亦支持 `-v` / `--version` |
---
## 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` → 光标处插入 `![描述](/api/images/{id})`;
- **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 pn ./cmd/pn
linux: web sync-assets
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
-ldflags="-s -w" -o pn-linux-amd64 ./cmd/pn
dev: # 双端热重载:air(Go)+ vite dev(proxy /api → 127.0.0.1:8080)+ start --dev
air & cd web && npm run dev
```
### 10.2 运行
```ini
# deploy/pn.service
[Unit]
Description=Pure Note
After=network.target
[Service]
User=purenote
WorkingDirectory=/opt/pure-note
ExecStart=/opt/pure-note/pn start --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
}
```
> 幂等启动:`start` 时若 settings 无口令哈希则拒绝启动并提示先跑 `init`;`user_version` 越界同样拒绝(§10.4)。
### 10.3 备份与恢复
**调度(systemd timer,替代 root cron)**
```ini
# deploy/pn-maint.timer
[Unit]
Description=Daily Pure Note maintenance (gc + backup)
[Timer]
OnCalendar=03:00
Persistent=true
[Install]
WantedBy=timers.target
```
timer 触发 `pn-maint.service`:先 `pn gc --commit`,后 `pn backup /backup/pn-$(date +\%F).db`。
**要点**
- 备份进程独立于服务进程运行,不受 unit 沙箱约束;`VACUUM INTO` 在 WAL 下与 start 并发安全(在线一致快照);
- `/backup` 目录属主 `purenote`、权限 0700;备份文件**chmod 0600**(内含全部私有笔记、图片、口令哈希、会话哈希的明文副本);
- 保留 30 天;**语义声明**:备份保留期内被删除的数据物理上仍存在于备份中;
- 异地同步(可选):`rclone` 远端启用 **crypt**(或先 `age` 加密再上传),禁止明文上云;
- **恢复 SOP**:停服 → 删除 `data/pn.db-wal` 与 `-shm`(残留会污染还原)→ 以备份文件替换 `pn.db` → 起服。
### 10.4 升级
1. **升级前第一步固定为 `pn 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` 产出可运行二进制;`start` 起服务;`/api/health` 200 |
| M1 公开浏览 | 笔记/标签 API + React 博客三页 + Markdown 渲染管线 + RSS/sitemap + 元信息注入(可见性规则) | 可见性矩阵**公开侧**用例起步;**构建冒烟测试**入 CI |
| M2 管理端 | 登录/会话/CSRF(`/api/me` 重取)+ 笔记 CRUD + 回收站 + 图片上传(魔数/缓存头分流)+ 编辑器 + 改密 | **表驱动可见性矩阵完整**(§13)+ 限流/CSRF/魔数/回收站用例**随功能同 PR 交付** |
| M3 安全加固 | 全量 §9.3 检查单、CSP 确认(记录 nonce 硬化待办)、CI 齐备(vet+vulncheck+测试+冒烟) | §9.3 全项通过 |
| 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 | 样式体系 |
| antd + antd-style | 6.6.3 / 4.1.0 | 组件库(ConfigProvider 自定义主题 + createStyles 桥接) |
| 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/ant-design/ant-design · 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)入库。