- 编辑页标题改 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,公开)与程序生成的示例图片
49 KiB
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 摘要)
- 图片存 SQLite BLOB 而不是文件系统 —— 满足「只有一个数据文件」字面含义:备份=拷一个文件;事务一致性;杜绝路径穿越整类风险(文件永不落盘执行)。个人博客场景(单图 ≤5MB、总量 2GB 内)SQLite 毫无压力(理论上限 281TB)。>10MB 的图片一律拒绝引导压缩。
- SPA 而非 SSR —— 管理端+浏览端共用 React 技术栈,极简;SEO 用「服务端渲染 index.html 元信息 + RSS + sitemap」弥补,元信息注入与内容 API 同一条可见性规则(§8.3)。「公开页 Go 模板 SSR」留作 v2 备选(见 §14)。
- HTTP 用标准库 —— Go 1.22+ ServeMux 已具备方法路由与
{param}通配符;本项目 API 约 20 个路由,框架收益趋近于零,依赖面以「less is more」取胜。 - 会话自建而非引库 —— 单管理员、单表、几十行代码,审计面最小;需求升级时切 scs 的成本极低。
- 图片可见性用「引用表」+ 并集语义 —— 图片通过
image_refs关联笔记,可见性 = 所有引用笔记可见性的并集(任一 public 引用即整体公开),并显式声明「已公开过的图片视为已分发,转私无法撤回已泄露副本」(§6.2、§9.1)。 - 编辑器选 CodeMirror 6 路线 —— 与「极简健壮」一致:所见即所写,不需要维护富文本中间态;组件边界(MarkdownViewer 独立)保证日后可平替 Tiptap。
- 回收站用软删除(
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)
-- 连接串(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):
- 物理删除
deleted_at < now−30天的笔记(refs 级联); - 删除图片:0 引用 且
created_at < now−7天(宽限期防止误删编辑中刚上传的图); - 删除过期会话。
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 认证与会话
- 初始化:
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>存库——参数随哈希走,未来调参可继续校验旧口令; - 登录:Origin/Referer→Host 校验通过后,per-IP 10 次/5 分钟 + 账号维度 5 次/10 分钟令牌桶(只计失败尝试);校验用
subtle.ConstantTimeCompare;失败统一返回 401「用户名或密码错误」不泄露差异; - 会话 Cookie:
__Host-pn_session= 256bit 随机 token;属性Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=7d(__Host-前缀自带 Secure + 根路径约束); - CSRF:会话行内存随机
csrf_token,登录响应下发、前端仅存内存并附到所有/api/admin/*变更请求的X-CSRF-Token头;刷新页面后经GET /api/me重取;明确禁止任何 localStorage 持久化;服务端同时校验 Origin/Referer 与 Host 匹配; - 轮换:活跃访问距过期 < 3 天时重建会话行、旋转 Cookie token,csrf_token 保持不变(前端无感);
- 登出:删除会话行;
- 改密:
POST /api/admin/password校验旧密码后写新 PHC 哈希;改密不失效当前会话(可选:改密后删除其他会话——单管理员场景仅本会话,不强制); - 口令重置(CLI):
pn passwd免旧口令直接覆盖哈希并吊销全部会话——前提是具备服务器访问权限(读写数据目录即可改库,CLI 提供规范入口优于手工改库);口令来源与init一致,避免出现在 argv; - 开发模式:
--dev允许非 Secure Cookie(否则__Host-在http://局域网IP下无法登录);强制约束:仅当监听地址为 loopback 时允许启动,防止误部署到生产。
7.4 图片上传与访问
- 上传:
Content-Type白名单 + 魔数字节校验(PNG/JPEG/WebP/GIF 签名,不信任客户端声明;SVG 一律拒绝——携带脚本风险)→ ≤5MB → 读入内存计算 sha256 →INSERT OR IGNORE去重 → 返回 URL;图片永不落盘,杜绝 WebShell 执行面; - 响应头按可见性分流:
- 公开图:
Content-Type(入库 mime)、X-Content-Type-Options: nosniff、ETag: "<sha256>"、Cache-Control: public, max-age=31536000, immutable; - 非公开图(仅私有笔记引用/孤儿/仅管理员会话):
Cache-Control: private, no-store,每次请求重新执行可见性判定; - 未授权与不存在统一 404(防自增 ID 枚举);
- 公开图:
- 可选增强(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 |
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→ 光标处插入; - 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 构建与嵌入约定
- 产物位置与 embed 机制(审评 P0-2 修正):
go:embed不能跨包引用,构建链为:Vite 输出web/dist→ Makefilesync-assets目标拷贝至internal/webui/dist→go:embed dist/*+fs.Sub在internal/webui包内嵌入;internal/webui/dist进 .gitignore(保留.gitkeep占位供 M0 编译); - Vite base(审评 P0-1 修正):部署在域名根,使用默认
base: '/'。⚠️ 相对 base('./')会在/notes/:slug等深链下把资源解析到/notes/assets/…导致白屏,禁止使用;embed handler 从站点根提供 FS,绝对路径恰好正确命中; - SPA fallback:
/api/*之外命中文件则返回,未命中(SPA 路由)回退index.html; - 元信息注入(审评 P0-4 修正):请求
/notes/{slug}时服务端查询该笔记,**仅当可见(public或管理员会话)**才注入<title>/<meta description>/<meta og:*>,否则回退站点默认 meta;注入经html/template自动转义,防标题内容打断标签结构; - 缓存与指纹:构建产物内容 hash 文件名 + 长缓存头(嵌入二进制,指纹天然隔离新旧版本);
- 冒烟测试守门(进 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 构建
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 运行
# 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
# 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)
# 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 升级
- 升级前第一步固定为
pn backup(写入 SOP); - 停服 → 替换二进制 → 起服触发迁移:迁移仅追加式(不删列/重命名/改类型),
user_version顺序递增; - 启动守卫:代码校验
user_version ≤ 本版本支持的最高版本,超出拒绝启动并提示,--allow-newer显式放行(防止旧二进制静默在陌生 schema 上读写); - 回滚:若迁移尚未执行(版本一致)可直接换回旧二进制;若已执行且不满足追加式约束,回滚 = 从步骤 1 的备份还原数据文件后再换回旧二进制。
10.5 观测性与开发工作流
- 日志:标准库
log/slog;--log-format text(journald 友好)或json;登录失败记 IP 与桶剩余计数;管理操作(增删改)记审计条目;不做纯请求量日志; - 优雅停机:SIGTERM/SIGINT →
http.Server.Shutdown(10s 排空)→ SQLite 连接关闭;systemdExecStop无需特殊配置; - 本地开发:
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)入库。