From edfc787e1a532688d25b0c22add3593195a63414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A0=81=E5=86=9C=E9=98=BF=E6=A5=A0?= Date: Tue, 8 Sep 2026 17:01:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BA=8C=E8=BF=9B=E5=88=B6=E5=8F=8A=E8=87=AA?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=91=BD=E5=90=8D=E7=BB=9F=E4=B8=80=E7=AE=80?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- Makefile | 8 +-- README.md | 30 +++++------ cmd/{pure-note => pn}/main.go | 42 ++++++++-------- deploy/pn-maint.service | 11 ++++ .../{pure-note-maint.timer => pn-maint.timer} | 2 +- deploy/{pure-note.service => pn.service} | 4 +- deploy/pure-note-maint.service | 11 ---- docs/acceptance.md | 8 +-- docs/decisions.md | 2 +- docs/design.md | 50 +++++++++---------- docs/review-round1.md | 4 +- internal/config/config.go | 12 ++--- internal/config/config_test.go | 10 ++-- internal/httpapi/auth.go | 2 +- internal/httpapi/testenv_test.go | 2 +- internal/store/store_test.go | 4 +- scripts/smoke.sh | 4 +- 18 files changed, 105 insertions(+), 105 deletions(-) rename cmd/{pure-note => pn}/main.go (87%) create mode 100644 deploy/pn-maint.service rename deploy/{pure-note-maint.timer => pn-maint.timer} (75%) rename deploy/{pure-note.service => pn.service} (83%) delete mode 100644 deploy/pure-note-maint.service diff --git a/.gitignore b/.gitignore index ce3c374..d8da080 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ internal/webui/dist/* !internal/webui/dist/.gitkeep # 本地构建的二进制 -/pure-note -/pure-note-linux-* +/pn +/pn-linux-* # 系统文件 .DS_Store \ No newline at end of file diff --git a/Makefile b/Makefile index 95cbf56..8303261 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # 常用目标: # make web 构建前端(npm ci + vite build → web/dist) # make sync-assets 拷贝 web/dist → internal/webui/dist(go:embed 约束,§8.3-1) -# make build 全量构建(前端 + 单二进制) +# make build 全量构建(前端 + 单二进制 pn) # make linux 交叉编译 linux/amd64 # make test Go 测试 + 前端测试 # make smoke 真实构建冒烟测试(§8.3-6) @@ -27,11 +27,11 @@ sync-assets: touch internal/webui/dist/.gitkeep build: web sync-assets - CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o pure-note ./cmd/pure-note + CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o pn ./cmd/pn linux: web sync-assets CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="$(LDFLAGS)" \ - -o pure-note-linux-amd64 ./cmd/pure-note + -o pn-linux-amd64 ./cmd/pn test: go vet ./... @@ -50,4 +50,4 @@ vulncheck: govulncheck ./... clean: - rm -f pure-note pure-note-linux-amd64 + rm -f pn pn-linux-amd64 diff --git a/README.md b/README.md index 58a40e5..f3eafb4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - **博客**:首页(置顶优先 + 分页 + 标签云)、详情(上一篇/下一篇)、标签页、RSS、sitemap、SEO meta 注入(仅公开内容) - **管理**:单管理员口令登录(Argon2id)、会话 7 天滑动续期(CSRF 轮换保持不变)、防爆破限流(per-IP + per-账号)、改密 - **回收站**:删除 = 软删除,30 天内可恢复,`gc` 到期物理清除 -- **运维**:`init` / `reset-password`(重设口令并吊销全部会话)/ `serve` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version` +- **运维**:`init` / `passwd`(重设口令并吊销全部会话)/ `start` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`(亦可 `-v`) ## 快速开始 @@ -20,11 +20,11 @@ make build # 2. 初始化(设置管理员口令与站点标题) -./pure-note init -# 非交互:PN_ADMIN_PASSWORD=xxx PN_SITE_TITLE=yyy ./pure-note init +./pn init +# 非交互:PN_ADMIN_PASSWORD=xxx PN_SITE_TITLE=yyy ./pn init # 3. 启动 -./pure-note serve --addr 127.0.0.1:8080 +./pn start --addr 127.0.0.1:8080 # 4. 访问 # 博客: http://127.0.0.1:8080/ @@ -34,7 +34,7 @@ make build 开发模式(本机调试,Cookie 允许非 Secure,仅限 loopback 监听): ```bash -./pure-note serve --dev --addr 127.0.0.1:8080 +./pn start --dev --addr 127.0.0.1:8080 # 前端热重载(另开终端): cd web && npm install && npm run dev # /api 代理到 127.0.0.1:8080 ``` @@ -42,8 +42,8 @@ cd web && npm install && npm run dev # /api 代理到 127.0.0.1:8080 忘记口令(CLI 可达即具备服务器权限,可直接重设;重设后全部会话被吊销): ```bash -./pure-note reset-password -# 非交互:PN_ADMIN_PASSWORD=xxx ./pure-note reset-password +./pn passwd +# 非交互:PN_ADMIN_PASSWORD=xxx ./pn passwd ``` ## 测试 @@ -60,25 +60,25 @@ slug 冲突策略、设置白名单(永不泄露口令哈希)、meta 注入 ## 部署(生产) ```bash -make linux # 产出 pure-note-linux-amd64 -sudo cp pure-note-linux-amd64 /opt/pure-note/pure-note -sudo -u purenote ./pure-note init --data-dir /opt/pure-note/data -sudo cp deploy/pure-note.service deploy/pure-note-maint.{service,timer} /etc/systemd/system/ -sudo systemctl enable --now pure-note pure-note-maint.timer +make linux # 产出 pn-linux-amd64 +sudo cp pn-linux-amd64 /opt/pure-note/pn +sudo -u purenote ./pn init --data-dir /opt/pure-note/data +sudo cp deploy/pn.service deploy/pn-maint.{service,timer} /etc/systemd/system/ +sudo systemctl enable --now pn pn-maint.timer ``` 反向代理用 Caddy(自动 TLS),示例见 [deploy/Caddyfile](deploy/Caddyfile); 启动需带 `--behind-proxy`(取 X-Forwarded-For 最右条目)。 -**升级 SOP**:`pure-note backup` → 停服 → 换二进制 → 起服(迁移自动执行; +**升级 SOP**:`pn backup` → 停服 → 换二进制 → 起服(迁移自动执行; 库版本高于代码支持范围时拒绝启动,`--allow-newer` 显式放行)。 -**恢复 SOP**:停服 → 删除 `data/pure-note.db-wal` 与 `-shm` → 以备份文件替换 `pure-note.db` → 起服。 +**恢复 SOP**:停服 → 删除 `data/pn.db-wal` 与 `-shm` → 以备份文件替换 `pn.db` → 起服。 ## 架构 ``` -cmd/pure-note/ CLI(serve/init/reset-password/backup/gc/version) +cmd/pn/ CLI(start/init/passwd/backup/gc/version) internal/config/ 命令行解析(--dev loopback 守卫) internal/store/ SQLite(modernc 纯 Go 驱动):迁移(user_version) + DAO + 备份 + gc internal/auth/ Argon2id(PHC) + 随机 token diff --git a/cmd/pure-note/main.go b/cmd/pn/main.go similarity index 87% rename from cmd/pure-note/main.go rename to cmd/pn/main.go index 56e5588..8249168 100644 --- a/cmd/pure-note/main.go +++ b/cmd/pn/main.go @@ -1,4 +1,4 @@ -// pure-note 单二进制入口:serve / init / reset-password / backup / gc / version(§7.6)。 +// pn 单二进制入口:start / init / passwd / backup / gc / version(§7.6)。 package main import ( @@ -38,18 +38,18 @@ func main() { } var err error switch os.Args[1] { - case "serve": - err = runServe(os.Args[2:]) + case "start": + err = runStart(os.Args[2:]) case "init": err = runInit(os.Args[2:]) - case "reset-password": + case "passwd": err = runResetPassword(os.Args[2:]) case "backup": err = runBackup(os.Args[2:]) case "gc": err = runGC(os.Args[2:]) - case "version": - fmt.Printf("pure-note %s (commit=%s, built=%s)\n", version, commit, buildDate) + case "version", "-v", "--version": + fmt.Printf("pn %s (commit=%s, built=%s)\n", version, commit, buildDate) case "help", "-h", "--help": usage() default: @@ -64,15 +64,15 @@ func main() { } func usage() { - fmt.Print(`pure-note — 极简高安全私人笔记 + 博客 + fmt.Print(`pn — 极简高安全私人笔记 + 博客 用法: - pure-note serve [--addr :8080] [--behind-proxy] [--dev] [--allow-newer] - pure-note init - pure-note reset-password # 重设管理员口令并吊销全部会话 - pure-note backup [输出路径] - pure-note gc [--commit] # 默认 dry-run - pure-note version + pn start [--addr :8080] [--behind-proxy] [--dev] [--allow-newer] + pn init + pn passwd # 重设管理员口令并吊销全部会话 + pn backup [输出路径] + pn gc [--commit] # 默认 dry-run + pn version # 亦可 -v / --version # --data-dir 可指定数据目录,默认 ./data `) @@ -98,9 +98,9 @@ func newLogger(cfg *config.Config) *slog.Logger { return slog.New(h) } -// runServe 启动 HTTP 服务(§7.2 服务器参数 + §10.5 优雅停机)。 -func runServe(args []string) error { - cfg, err := config.ParseServe(args) +// runStart 启动 HTTP 服务(§7.2 服务器参数 + §10.5 优雅停机)。 +func runStart(args []string) error { + cfg, err := config.ParseStart(args) if err != nil { return err } @@ -118,7 +118,7 @@ func runServe(args []string) error { if _, ok, err := st.GetSetting("admin_password_hash"); err != nil { return err } else if !ok { - return errors.New("尚未初始化管理员口令:请先执行 `pure-note init --data-dir " + cfg.DataDir + "`") + return errors.New("尚未初始化管理员口令:请先执行 `pn init --data-dir " + cfg.DataDir + "`") } srv := httpapi.New(st, cfg, log) @@ -157,7 +157,7 @@ func runServe(args []string) error { defer cancel() errCh := make(chan error, 1) go func() { - log.Info("pure-note 已启动", "addr", cfg.Addr, "data_dir", cfg.DataDir, "dev", cfg.Dev) + log.Info("pn 已启动", "addr", cfg.Addr, "data_dir", cfg.DataDir, "dev", cfg.Dev) errCh <- httpServer.ListenAndServe() }() @@ -226,7 +226,7 @@ func runInit(args []string) error { if err := st.SetSetting(store.KeySiteTitle, title); err != nil { return err } - fmt.Println("初始化完成。现在可以启动:pure-note serve --data-dir", cfg.DataDir) + fmt.Println("初始化完成。现在可以启动:pn start --data-dir", cfg.DataDir) return nil } @@ -234,7 +234,7 @@ func runInit(args []string) error { // 口令来源与 init 一致:环境变量 PN_ADMIN_PASSWORD 或交互输入(避免出现在 ps 参数里)。 // 重设成功后吊销全部会话,强制所有端重新登录。 func runResetPassword(args []string) error { - cfg, _, err := config.ParseMaint("reset-password", args) + cfg, _, err := config.ParseMaint("passwd", args) if err != nil { return err } @@ -311,7 +311,7 @@ func runBackup(args []string) error { dest = rest[0] } if dest == "" { - dest = filepath.Join(cfg.DataDir, fmt.Sprintf("pure-note-backup-%s.db", time.Now().Format("20060102-150405"))) + dest = filepath.Join(cfg.DataDir, fmt.Sprintf("pn-backup-%s.db", time.Now().Format("20060102-150405"))) } st, err := store.Open(cfg.DBPath(), false) if err != nil { diff --git a/deploy/pn-maint.service b/deploy/pn-maint.service new file mode 100644 index 0000000..88c5f28 --- /dev/null +++ b/deploy/pn-maint.service @@ -0,0 +1,11 @@ +# /etc/systemd/system/pn-maint.service +# 每日维护:先 gc(--commit)后备份(§10.3) +[Unit] +Description=Pure Note daily maintenance (gc + backup) +After=pn.service + +[Service] +Type=oneshot +User=purenote +ExecStart=/opt/pure-note/pn gc --data-dir /opt/pure-note/data --commit +ExecStart=/opt/pure-note/pn backup --data-dir /opt/pure-note/data /backup/pn-%%F.db diff --git a/deploy/pure-note-maint.timer b/deploy/pn-maint.timer similarity index 75% rename from deploy/pure-note-maint.timer rename to deploy/pn-maint.timer index 6e23942..fb32b01 100644 --- a/deploy/pure-note-maint.timer +++ b/deploy/pn-maint.timer @@ -1,4 +1,4 @@ -# /etc/systemd/system/pure-note-maint.timer +# /etc/systemd/system/pn-maint.timer [Unit] Description=Daily Pure Note maintenance (gc + backup) diff --git a/deploy/pure-note.service b/deploy/pn.service similarity index 83% rename from deploy/pure-note.service rename to deploy/pn.service index 2efacd5..4efb2b3 100644 --- a/deploy/pure-note.service +++ b/deploy/pn.service @@ -1,4 +1,4 @@ -# /etc/systemd/system/pure-note.service +# /etc/systemd/system/pn.service [Unit] Description=Pure Note - minimalist high-security private notes + blog After=network.target @@ -6,7 +6,7 @@ After=network.target [Service] User=purenote WorkingDirectory=/opt/pure-note -ExecStart=/opt/pure-note/pure-note serve --addr 127.0.0.1:8080 \ +ExecStart=/opt/pure-note/pn start --addr 127.0.0.1:8080 \ --data-dir /opt/pure-note/data --behind-proxy Restart=on-failure RestartSec=5 diff --git a/deploy/pure-note-maint.service b/deploy/pure-note-maint.service deleted file mode 100644 index b34ce4c..0000000 --- a/deploy/pure-note-maint.service +++ /dev/null @@ -1,11 +0,0 @@ -# /etc/systemd/system/pure-note-maint.service -# 每日维护:先 gc(--commit)后备份(§10.3) -[Unit] -Description=Pure Note daily maintenance (gc + backup) -After=pure-note.service - -[Service] -Type=oneshot -User=purenote -ExecStart=/opt/pure-note/pure-note gc --data-dir /opt/pure-note/data --commit -ExecStart=/opt/pure-note/pure-note backup --data-dir /opt/pure-note/data /backup/pure-note-%%F.db diff --git a/docs/acceptance.md b/docs/acceptance.md index 41f7c20..c6d9fe6 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -27,7 +27,7 @@ | 回收站恢复用例;gc dry-run→commit;7 天内孤儿图片不删 | ✅ | `TestTrashLifecycleAndGC` + CLI 实测(dry-run 报告、commit 执行、宽限期保留) | | user_version 越界启动被拒;--allow-newer 放行 | ✅ | `TestMigrationNewerRejected` + CLI 实测(拒启 exit=1;--allow-newer 后 /api/health 200) | | 构建冒烟测试通过 | ✅ | `make smoke`(npm ci → vite build → sync-assets → CGO_ENABLED=0 交叉构建 → 起服务 19 项断言:SPA 资源 200+MIME、meta 注入、安全头、私有不可见) | -| Caddy 仅开放 80/443、程序监听 127.0.0.1 | ✅(配置交付) | deploy/Caddyfile + pure-note.service(`--addr 127.0.0.1:8080 --behind-proxy`)——本机无生产部署环境,按配置文件验收 | +| Caddy 仅开放 80/443、程序监听 127.0.0.1 | ✅(配置交付) | deploy/Caddyfile + pn.service(`--addr 127.0.0.1:8080 --behind-proxy`)——本机无生产部署环境,按配置文件验收 | ## 三、§13 测试策略落地 @@ -70,11 +70,11 @@ ## 六、交付物清单 ``` -pure-note(单二进制,CGO_ENABLED=0,go:embed 前端产物) -├── cmd/pure-note/ serve / init / reset-password / backup / gc / version +pn(单二进制,CGO_ENABLED=0,go:embed 前端产物) +├── cmd/pn/ start / init / passwd / backup / gc / version ├── internal/{config,store,auth,markdown,middleware,httpapi,webui} ├── web/ React 19 + Vite 8 + Tailwind 4(vitest 6 用例) -├── deploy/ pure-note.service、pure-note-maint.{service,timer}、Caddyfile +├── deploy/ pn.service、pn-maint.{service,timer}、Caddyfile ├── scripts/smoke.sh 构建冒烟(make smoke) ├── Makefile / README.md / .gitignore └── docs/{design.md, review-round1.md, decisions.md, acceptance.md} diff --git a/docs/decisions.md b/docs/decisions.md index a76ab3f..045a22c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -10,7 +10,7 @@ | --- | --- | --- | | D1 | Go 工具链使用 **1.26.6**(设计写 1.27.1) | 本机已装版本。所用特性(1.22+ ServeMux 方法/通配符路由、slog、go:embed)全部满足,无升级必要 | | D2 | 依赖版本按设计锁定值解析:modernc.org/sqlite v1.58.0、goldmark v1.8.6、bluemonday v1.0.27、x/crypto v0.56.0、x/time v0.15.0 | 设计附录 A「实际开发时以 go mod tidy 解析到的版本为准」——解析结果与设计核实值完全一致 | -| D3 | 新增第 6 个直接依赖 **golang.org/x/term v0.45.0** | `pure-note init` 交互式口令输入需要关闭回显。设计 §2.2 预算「直接依赖 ≤6(Go 侧 5 个)」,6 个在预算内;`PN_ADMIN_PASSWORD` 环境变量仍是非交互主通道 | +| D3 | 新增第 6 个直接依赖 **golang.org/x/term v0.45.0** | `pn init` 交互式口令输入需要关闭回显。设计 §2.2 预算「直接依赖 ≤6(Go 侧 5 个)」,6 个在预算内;`PN_ADMIN_PASSWORD` 环境变量仍是非交互主通道 | | D4 | 前端 TypeScript 用 **5.9**(设计允许 7.x 或回退 5.9) | 直接采用设计声明的保守回退路径,规避第三方类型兼容风险(CodeMirror/react-markdown 生态) | | D5 | 前端其余依赖与设计一致:react 19.2.8、vite 8.2.2、tailwind 4.3.3、react-router 8.3.1、@tanstack/react-query 5.102.8、react-markdown 10.1.0 等,npm 解析 0 漏洞 | `npm install` 实测 | diff --git a/docs/design.md b/docs/design.md index 973cafb..a54be32 100644 --- a/docs/design.md +++ b/docs/design.md @@ -129,7 +129,7 @@ │ HTTPS (Caddy 自动 TLS, 反代, 追加密名 XFF) ▼ ┌──────────────────────────┐ - │ pure-note 单一二进制 (Go) │ + │ pn 单一二进制 (Go) │ │ │ │ net/http + ServeMux │ │ ├─ 公共 API /api/* │──┐ @@ -145,7 +145,7 @@ │ database/sql (SetMaxOpenConns=1) ▼ ┌──────────────────────────┐ - │ data/pure-note.db (SQLite)│ + │ data/pn.db (SQLite) │ │ notes(含 deleted_at) / │ │ images(BLOB) / image_refs /│ │ sessions / settings │ @@ -176,8 +176,8 @@ pure-note/ │ ├── design.md # 本文档(v1.1) │ └── review-round1.md # 第 1 轮审评报告(修订依据,留档) ├── cmd/ -│ └── pure-note/ -│ └── main.go # 入口 + 命令行(serve/init/reset-password/backup/gc/version) +│ └── pn/ +│ └── main.go # 入口 + 命令行(start/init/passwd/backup/gc/version) ├── internal/ │ ├── config/ # 环境变量/flag 解析 │ ├── store/ # SQLite 打开、迁移(user_version 上界校验)、DAO @@ -200,8 +200,8 @@ pure-note/ │ ├── features/ # notes / admin 业务 hooks │ └── pages/ # Home / Note / Tags / AdminLogin / AdminList / AdminTrash / AdminEdit └── deploy/ - ├── pure-note.service # systemd 服务单元 - ├── pure-note-maint.timer # 每日 gc + 备份 timer + ├── pn.service # systemd 服务单元 + ├── pn-maint.timer # 每日 gc + 备份 timer └── Caddyfile # 反代示例 ``` @@ -213,7 +213,7 @@ pure-note/ ```sql -- 连接串(DSN)统一注入以下 pragma: --- file:data/pure-note.db?_pragma=journal_mode(WAL) +-- file:data/pn.db?_pragma=journal_mode(WAL) -- &_pragma=busy_timeout(5000) -- &_pragma=foreign_keys(1) -- &_pragma=synchronous(NORMAL) @@ -281,7 +281,7 @@ CREATE TABLE IF NOT EXISTS settings ( | 迁移 | `PRAGMA user_version` 版本化,顺序执行内嵌迁移脚本;**仅追加式**(禁止删列/重命名/改类型);启动时校验 `user_version ≤ 代码支持的最高版本`,超出拒绝启动(`--allow-newer` 显式放行,§10.4) | | 并发 | `db.SetMaxOpenConns(1)` — 单写者串行化,WAL 下个人规模足够;规模上来后按「多读者连接池」扩展 | -### 6.3 数据清理(`pure-note gc`) +### 6.3 数据清理(`pn gc`) 默认 `--dry-run` 只输出清理计划,`--commit` 才执行;systemd timer 每日调用一次(§10.3): @@ -348,14 +348,14 @@ SecurityHeaders(含 HSTS,§9.2) ### 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$$` 存库**——参数随哈希走,未来调参可继续校验旧口令; +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$$` 存库**——参数随哈希走,未来调参可继续校验旧口令; 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)**:`pure-note reset-password` 免旧口令直接覆盖哈希并**吊销全部会话**——前提是具备服务器访问权限(读写数据目录即可改库,CLI 提供规范入口优于手工改库);口令来源与 `init` 一致,避免出现在 argv; +8. **口令重置(CLI)**:`pn passwd` 免旧口令直接覆盖哈希并**吊销全部会话**——前提是具备服务器访问权限(读写数据目录即可改库,CLI 提供规范入口优于手工改库);口令来源与 `init` 一致,避免出现在 argv; 9. **开发模式**:`--dev` 允许非 Secure Cookie(否则 `__Host-` 在 `http://局域网IP` 下无法登录);**强制约束:仅当监听地址为 loopback 时允许启动,防止误部署到生产**。 ### 7.4 图片上传与访问 @@ -376,12 +376,12 @@ SecurityHeaders(含 HSTS,§9.2) | 子命令 / 开关 | 说明 | | --- | --- | -| `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 reset-password` | 重设管理员口令(覆盖旧哈希)并吊销全部会话;口令来源同 `init`(环境变量 `PN_ADMIN_PASSWORD` 或交互输入,不经 argv 避免 ps 泄露)。CLI 可达即具备服务器权限,允许直接重设 | -| `pure-note backup [path]` | 在线备份:`VACUUM INTO`(一致快照,不停服);默认输出 0600 权限 | -| `pure-note gc` | 回收站过期清除 + 孤儿图片清除 + 会话清理;**默认 `--dry-run`**,`--commit` 才执行(§6.3) | -| `pure-note version` | 版本号(build 时注入 commit/时间) | +| `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)、站点标题 | +| `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` | --- @@ -522,20 +522,20 @@ sync-assets: build: web sync-assets CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \ - -o pure-note ./cmd/pure-note + -o pn ./cmd/pn 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 + -ldflags="-s -w" -o pn-linux-amd64 ./cmd/pn -dev: # 双端热重载:air(Go)+ vite dev(proxy /api → 127.0.0.1:8080)+ serve --dev +dev: # 双端热重载:air(Go)+ vite dev(proxy /api → 127.0.0.1:8080)+ start --dev air & cd web && npm run dev ``` ### 10.2 运行 ```ini -# deploy/pure-note.service +# deploy/pn.service [Unit] Description=Pure Note After=network.target @@ -543,7 +543,7 @@ After=network.target [Service] User=purenote WorkingDirectory=/opt/pure-note -ExecStart=/opt/pure-note/pure-note serve --addr 127.0.0.1:8080 \ +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 @@ -571,7 +571,7 @@ example.com { **调度(systemd timer,替代 root cron)** ```ini -# deploy/pure-note-maint.timer +# deploy/pn-maint.timer [Unit] Description=Daily Pure Note maintenance (gc + backup) @@ -583,7 +583,7 @@ Persistent=true WantedBy=timers.target ``` -timer 触发 `pure-note-maint.service`:先 `pure-note gc --commit`,后 `pure-note backup /backup/pure-note-$(date +\%F).db`。 +timer 触发 `pn-maint.service`:先 `pn gc --commit`,后 `pn backup /backup/pn-$(date +\%F).db`。 **要点** @@ -591,11 +591,11 @@ timer 触发 `pure-note-maint.service`:先 `pure-note gc --commit`,后 `pure - `/backup` 目录属主 `purenote`、权限 0700;备份文件**chmod 0600**(内含全部私有笔记、图片、口令哈希、会话哈希的明文副本); - 保留 30 天;**语义声明**:备份保留期内被删除的数据物理上仍存在于备份中; - 异地同步(可选):`rclone` 远端启用 **crypt**(或先 `age` 加密再上传),禁止明文上云; -- **恢复 SOP**:停服 → 删除 `data/pure-note.db-wal` 与 `-shm`(残留会污染还原)→ 以备份文件替换 `pure-note.db` → 起服。 +- **恢复 SOP**:停服 → 删除 `data/pn.db-wal` 与 `-shm`(残留会污染还原)→ 以备份文件替换 `pn.db` → 起服。 ### 10.4 升级 -1. **升级前第一步固定为 `pure-note backup`**(写入 SOP); +1. **升级前第一步固定为 `pn backup`**(写入 SOP); 2. 停服 → 替换二进制 → 起服触发迁移:迁移仅追加式(不删列/重命名/改类型),`user_version` 顺序递增; 3. **启动守卫**:代码校验 `user_version ≤ 本版本支持的最高版本`,超出**拒绝启动**并提示,`--allow-newer` 显式放行(防止旧二进制静默在陌生 schema 上读写); 4. 回滚:若迁移尚未执行(版本一致)可直接换回旧二进制;若已执行且不满足追加式约束,回滚 = 从步骤 1 的备份还原数据文件后再换回旧二进制。 diff --git a/docs/review-round1.md b/docs/review-round1.md index a015ec8..0f4dee0 100644 --- a/docs/review-round1.md +++ b/docs/review-round1.md @@ -139,7 +139,7 @@ T10 点名了 API/RSS/sitemap/图片,唯独漏了 tags;API 表里只写「 ### P1-13 升级回滚不严谨(§10.4) 「回滚 = 换回旧二进制(迁移可逆或旧版本可忽略新表)」不成立:`user_version` 是单向整数,旧二进制看到更高版本**不会报错**,会静默在陌生 schema 上读写,把可恢复事故变成不可恢复。「向前兼容」一词也用反了。 -**修复**:启动时校验 `user_version ≤ 代码支持的最高版本`,超出拒绝启动(留 `--allow-newer` 旗标);迁移策略声明为「仅追加式」(禁止删列/重命名/改类型);升级 SOP 第一步固定为 `pure-note backup`。 +**修复**:启动时校验 `user_version ≤ 代码支持的最高版本`,超出拒绝启动(留 `--allow-newer` 旗标);迁移策略声明为「仅追加式」(禁止删列/重命名/改类型);升级 SOP 第一步固定为 `pn backup`。 ### P1-14 测试策略缺失(§12 / §9.3) @@ -149,7 +149,7 @@ T10 点名了 API/RSS/sitemap/图片,唯独漏了 tags;API 表里只写「 ### 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`」;声明备份保留与删除的关系。 +**修复**:备份文件 chmod 0600 / 专用低权用户;异地前强制加密(age / rclone crypt);恢复 SOP 补「删除 `pn.db-wal` 与 `-shm`」;声明备份保留与删除的关系。 --- diff --git a/internal/config/config.go b/internal/config/config.go index 5e920e1..112d68b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,7 +20,7 @@ type Config struct { } // DBPath SQLite 数据库文件路径。 -func (c *Config) DBPath() string { return c.DataDir + "/pure-note.db" } +func (c *Config) DBPath() string { return c.DataDir + "/pn.db" } func addCommonFlags(fs *flag.FlagSet, c *Config) { fs.StringVar(&c.DataDir, "data-dir", "./data", "数据目录(SQLite 数据库所在)") @@ -36,10 +36,10 @@ func addServeFlags(fs *flag.FlagSet, c *Config) { fs.BoolVar(&c.Dev, "dev", false, "开发模式:允许非 Secure Cookie,仅允许监听 loopback 地址") } -// ParseServe 解析 `pure-note serve` 参数。 -func ParseServe(args []string) (*Config, error) { +// ParseStart 解析 `pn start` 参数。 +func ParseStart(args []string) (*Config, error) { c := &Config{} - fs := flag.NewFlagSet("serve", flag.ContinueOnError) + fs := flag.NewFlagSet("start", flag.ContinueOnError) addServeFlags(fs, c) if err := fs.Parse(args); err != nil { return nil, err @@ -50,7 +50,7 @@ func ParseServe(args []string) (*Config, error) { return c, nil } -// ParseInit 解析 `pure-note init` 参数。 +// ParseInit 解析 `pn init` 参数。 func ParseInit(args []string) (*Config, error) { c := &Config{} fs := flag.NewFlagSet("init", flag.ContinueOnError) @@ -61,7 +61,7 @@ func ParseInit(args []string) (*Config, error) { return c, nil } -// ParseMaint 解析 `pure-note backup` / `pure-note gc` 参数(均只需数据目录)。 +// ParseMaint 解析 `pn backup` / `pn gc` / `pn passwd` 参数(均只需数据目录)。 // 返回 flag 解析后的剩余位置参数(如 backup 的输出路径)。 func ParseMaint(cmd string, args []string) (*Config, []string, error) { c := &Config{} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 286ea50..abf331a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,20 +4,20 @@ import "testing" func TestDevLoopbackGuard(t *testing.T) { // --dev 强制 loopback(§7.3-8) - if _, err := ParseServe([]string{"--dev", "--addr", ":8080"}); err == nil { + if _, err := ParseStart([]string{"--dev", "--addr", ":8080"}); err == nil { t.Error("--dev + 0.0.0.0 应拒绝启动") } - if _, err := ParseServe([]string{"--dev", "--addr", "192.168.1.5:8080"}); err == nil { + if _, err := ParseStart([]string{"--dev", "--addr", "192.168.1.5:8080"}); err == nil { t.Error("--dev + 局域网地址应拒绝启动") } - if _, err := ParseServe([]string{"--dev", "--addr", "127.0.0.1:8080"}); err != nil { + if _, err := ParseStart([]string{"--dev", "--addr", "127.0.0.1:8080"}); err != nil { t.Errorf("--dev + 127.0.0.1 应允许: %v", err) } - if _, err := ParseServe([]string{"--dev", "--addr", "localhost:8080"}); err != nil { + if _, err := ParseStart([]string{"--dev", "--addr", "localhost:8080"}); err != nil { t.Errorf("--dev + localhost 应允许: %v", err) } // 非 dev 不限制 - if _, err := ParseServe([]string{"--addr", ":8080"}); err != nil { + if _, err := ParseStart([]string{"--addr", ":8080"}); err != nil { t.Errorf("非 dev 任意地址应允许: %v", err) } } diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index 71248a0..0c2f711 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -39,7 +39,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } if !ok { - writeError(w, http.StatusInternalServerError, "not_initialized", "尚未初始化管理员口令,请先执行 pure-note init") + writeError(w, http.StatusInternalServerError, "not_initialized", "尚未初始化管理员口令,请先执行 pn init") return } diff --git a/internal/httpapi/testenv_test.go b/internal/httpapi/testenv_test.go index bd5af8a..39c300f 100644 --- a/internal/httpapi/testenv_test.go +++ b/internal/httpapi/testenv_test.go @@ -53,7 +53,7 @@ func newEnvCustom(t *testing.T, global, loginIP, loginAcct *middleware.Limiter) loginAcct = middleware.NewLimiter(1e9, 1<<20, 1<<20, time.Minute) } dir := t.TempDir() - st, err := store.Open(dir+"/pure-note.db", false) + st, err := store.Open(dir+"/pn.db", false) if err != nil { t.Fatalf("打开测试库失败: %v", err) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index cdbc94c..af60f6f 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -9,7 +9,7 @@ import ( // TestMigrationsFromEmpty 空库 → 最新版本;重复打开幂等(§13 迁移组)。 func TestMigrationsFromEmpty(t *testing.T) { dir := t.TempDir() - dbPath := filepath.Join(dir, "pure-note.db") + dbPath := filepath.Join(dir, "pn.db") s1, err := Open(dbPath, false) if err != nil { t.Fatalf("首次打开失败: %v", err) @@ -41,7 +41,7 @@ func TestMigrationsFromEmpty(t *testing.T) { // TestMigrationNewerRejected user_version 越界拒绝启动;--allow-newer 放行(§10.4)。 func TestMigrationNewerRejected(t *testing.T) { dir := t.TempDir() - dbPath := filepath.Join(dir, "pure-note.db") + dbPath := filepath.Join(dir, "pn.db") s1, err := Open(dbPath, false) if err != nil { t.Fatal(err) diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 7a727ed..ca72c3b 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -5,7 +5,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -BIN="$ROOT/pure-note" +BIN="$ROOT/pn" TMP="$(mktemp -d)" PORT="${SMOKE_PORT:-18099}" BASE="http://127.0.0.1:$PORT" @@ -22,7 +22,7 @@ echo "[smoke] 初始化数据目录" PN_ADMIN_PASSWORD=smoke-password-123 PN_SITE_TITLE="Smoke 站" "$BIN" init --data-dir "$TMP/data" echo "[smoke] 启动服务 :$PORT" -"$BIN" serve --data-dir "$TMP/data" --addr "127.0.0.1:$PORT" --dev >"$TMP/serve.log" 2>&1 & +"$BIN" start --data-dir "$TMP/data" --addr "127.0.0.1:$PORT" --dev >"$TMP/serve.log" 2>&1 & SRV_PID=$! for _ in $(seq 1 50); do curl -sf "$BASE/api/health" >/dev/null 2>&1 && break