二进制及自命令命名统一简化

This commit is contained in:
2026-09-08 17:01:09 +08:00
parent 6bfe20ee00
commit edfc787e1a
18 changed files with 105 additions and 105 deletions
+2 -2
View File
@@ -10,8 +10,8 @@ internal/webui/dist/*
!internal/webui/dist/.gitkeep
# 本地构建的二进制
/pure-note
/pure-note-linux-*
/pn
/pn-linux-*
# 系统文件
.DS_Store
+4 -4
View File
@@ -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
+15 -15
View File
@@ -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
+21 -21
View File
@@ -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 {
+11
View File
@@ -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
@@ -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)
@@ -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
-11
View File
@@ -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
+4 -4
View File
@@ -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}
+1 -1
View File
@@ -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` 实测 |
+25 -25
View File
@@ -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$<b64salt>$<b64hash>` 存库**——参数随哈希走,未来调参可继续校验旧口令;
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)**:`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 的备份还原数据文件后再换回旧二进制。
+2 -2
View File
@@ -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`」;声明备份保留与删除的关系。
---
+6 -6
View File
@@ -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{}
+5 -5
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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