fix(store): 修正邻接查询、GC 竞态、备份权限与设置解析,新增 OpenData 纯数据打开
邻接查询 tie-break 与 pinned 排序键与列表序对齐,补表驱动测试(round2 P1-1); GC DELETE 带条件复查,防 SELECT 与 DELETE 间恢复/引用竞态(P2-6); 备份经 umask 收紧创建即 0600,消除 chmod 前暴露窗口(P2-7); page_size 改 strconv.Atoi 全文解析,拒部分解析脏值(P2-13); OpenData 不迁移、不做版本守卫(D6/P1-5)。
This commit is contained in:
+25
-4
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,15 +87,29 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
|
|||||||
return rep, nil
|
return rep, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 条件复查删除(防 TOCTOU:SELECT 与 DELETE 之间管理员恢复笔记/图片被
|
||||||
|
// 重新引用时,不删活数据;评审 round2 P2-6)
|
||||||
for _, en := range rep.ExpiredNotes {
|
for _, en := range rep.ExpiredNotes {
|
||||||
if err := s.DeleteNoteForever(en.ID); err != nil {
|
res, err := s.db.Exec(
|
||||||
|
`DELETE FROM notes WHERE id=? AND deleted_at IS NOT NULL AND deleted_at < ?`,
|
||||||
|
en.ID, noteCutoff)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("物理删除笔记 %d: %w", en.ID, err)
|
return nil, fmt.Errorf("物理删除笔记 %d: %w", en.ID, err)
|
||||||
}
|
}
|
||||||
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||||
|
continue // 期间已被恢复,跳过
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, img := range rep.OrphanImages {
|
for _, img := range rep.OrphanImages {
|
||||||
if err := s.DeleteImage(img.ID); err != nil {
|
res, err := s.db.Exec(
|
||||||
|
`DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?)`,
|
||||||
|
img.ID, img.ID)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err)
|
return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err)
|
||||||
}
|
}
|
||||||
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||||
|
continue // 期间已被引用,跳过
|
||||||
|
}
|
||||||
}
|
}
|
||||||
n, err := s.DeleteExpiredSessions(now.Unix())
|
n, err := s.DeleteExpiredSessions(now.Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -109,11 +124,17 @@ func (s *Store) Backup(destPath string) error {
|
|||||||
if _, err := os.Stat(destPath); err == nil {
|
if _, err := os.Stat(destPath); err == nil {
|
||||||
return fmt.Errorf("目标文件已存在: %s", destPath)
|
return fmt.Errorf("目标文件已存在: %s", destPath)
|
||||||
}
|
}
|
||||||
|
// 备份含全部私密内容:临时收紧 umask,确保 VACUUM INTO 创建即 0600
|
||||||
|
// (否则以默认 umask(常 0644)创建、事后 chmod 存在暴露窗口,评审 round2 P2-7)。
|
||||||
|
// backup 为 CLI 单线程进程,进程级 umask 无并发副作用。
|
||||||
|
old := syscall.Umask(0o077)
|
||||||
// VACUUM INTO 不接受参数绑定,路径经单引号转义(无参数化通道时的最小注入面)。
|
// VACUUM INTO 不接受参数绑定,路径经单引号转义(无参数化通道时的最小注入面)。
|
||||||
if _, err := s.db.Exec("VACUUM INTO " + escapeSQLString(destPath)); err != nil {
|
_, err := s.db.Exec("VACUUM INTO " + escapeSQLString(destPath))
|
||||||
|
syscall.Umask(old)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// 备份含全部私钥内容:强制 0600(§10.3)
|
// 双保险:显式收紧(幂等)
|
||||||
if err := os.Chmod(destPath, 0o600); err != nil {
|
if err := os.Chmod(destPath, 0o600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-9
@@ -183,23 +183,26 @@ func (s *Store) ListPublicNotes(page, pageSize int, tag string) ([]Note, int, er
|
|||||||
return out, total, rows.Err()
|
return out, total, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdjacentPublicNote 返回公开序列中与 n 相邻的上一篇/下一篇
|
// AdjacentPublicNote 返回公开列表中与 n 相邻的上一篇/下一篇。
|
||||||
// (按列表序 updated_at DESC, id DESC)。仅返回 slug 与 title。
|
// 列表序为 pinned DESC, updated_at DESC, id DESC(notes.go ListPublicNotes),
|
||||||
|
// prev = 列表中紧邻 n 之前的一篇(元组 (pinned, updated_at, id) 字典序更大侧的最小项),
|
||||||
|
// next = 紧邻之后的一篇(字典序更小侧的最大项)。仅返回 slug 与 title。
|
||||||
func (s *Store) AdjacentPublicNote(n *Note, dir string) (slug, title string, err error) {
|
func (s *Store) AdjacentPublicNote(n *Note, dir string) (slug, title string, err error) {
|
||||||
|
p := boolToInt(n.Pinned)
|
||||||
var q string
|
var q string
|
||||||
switch dir {
|
switch dir {
|
||||||
case "prev": // 列表中更早的一篇
|
case "prev": // 列表中位于 n 之前的一篇
|
||||||
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
|
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
|
||||||
AND (updated_at > ? OR (updated_at = ? AND id < ?))
|
AND (pinned > ? OR (pinned = ? AND (updated_at > ? OR (updated_at = ? AND id > ?))))
|
||||||
ORDER BY updated_at ASC, id DESC LIMIT 1`
|
ORDER BY pinned ASC, updated_at ASC, id ASC LIMIT 1`
|
||||||
case "next": // 列表中更新的一篇
|
case "next": // 列表中位于 n 之后的一篇
|
||||||
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
|
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
|
||||||
AND (updated_at < ? OR (updated_at = ? AND id > ?))
|
AND (pinned < ? OR (pinned = ? AND (updated_at < ? OR (updated_at = ? AND id < ?))))
|
||||||
ORDER BY updated_at DESC, id ASC LIMIT 1`
|
ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT 1`
|
||||||
default:
|
default:
|
||||||
return "", "", fmt.Errorf("dir 必须为 prev|next")
|
return "", "", fmt.Errorf("dir 必须为 prev|next")
|
||||||
}
|
}
|
||||||
row := s.db.QueryRow(q, n.UpdatedAt, n.UpdatedAt, n.ID)
|
row := s.db.QueryRow(q, p, p, n.UpdatedAt, n.UpdatedAt, n.ID)
|
||||||
if err := row.Scan(&slug, &title); err != nil {
|
if err := row.Scan(&slug, &title); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return "", "", nil
|
return "", "", nil
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ package store
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Settings 键白名单(§7.1 SettingsDTO 同源;admin_password_hash 永不进入 API 响应)。
|
// Settings 键白名单(§7.1 SettingsDTO 同源;admin_password_hash 永不进入 API 响应)。
|
||||||
@@ -61,8 +62,8 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) {
|
|||||||
if v, ok, err := s.GetSetting(KeyPageSize); err != nil {
|
if v, ok, err := s.GetSetting(KeyPageSize); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
var n int
|
// strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13)
|
||||||
if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n >= 1 && n <= 100 {
|
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 1 && n <= 100 {
|
||||||
ss.PageSize = n
|
ss.PageSize = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-10
@@ -80,8 +80,32 @@ type Store struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open 打开数据库并执行迁移。dbPath 为文件绝对/相对路径。
|
// Open 打开数据库并执行迁移(start/init 用)。dbPath 为文件绝对/相对路径。
|
||||||
|
// allowNewer 跳过 user_version 上界守卫(§10.4)。
|
||||||
func Open(dbPath string, allowNewer bool) (*Store, error) {
|
func Open(dbPath string, allowNewer bool) (*Store, error) {
|
||||||
|
db, err := openDB(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := migrateDB(db, allowNewer); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Store{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenData 打开数据库仅做数据操作:**不迁移、不做版本守卫**
|
||||||
|
// (passwd/backup/gc 维护子命令,D6:在更新 schema 的库上备份/清理
|
||||||
|
// 不应被守卫阻断,也不应产生迁移副作用)。
|
||||||
|
func OpenData(dbPath string) (*Store, error) {
|
||||||
|
db, err := openDB(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Store{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDB(dbPath string) (*sql.DB, error) {
|
||||||
// SQLite URI 不接受相对路径
|
// SQLite URI 不接受相对路径
|
||||||
abs, err := filepath.Abs(dbPath)
|
abs, err := filepath.Abs(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -101,24 +125,19 @@ func Open(dbPath string, allowNewer bool) (*Store, error) {
|
|||||||
}
|
}
|
||||||
// 单写者串行化(§6.2)
|
// 单写者串行化(§6.2)
|
||||||
db.SetMaxOpenConns(1)
|
db.SetMaxOpenConns(1)
|
||||||
s := &Store{db: db}
|
return db, nil
|
||||||
if err := s.migrate(allowNewer); err != nil {
|
|
||||||
db.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) migrate(allowNewer bool) error {
|
func migrateDB(db *sql.DB, allowNewer bool) error {
|
||||||
var v int
|
var v int
|
||||||
if err := s.db.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
|
if err := db.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if v > MaxSchemaVersion && !allowNewer {
|
if v > MaxSchemaVersion && !allowNewer {
|
||||||
return fmt.Errorf("%w: 库版本 %d > 支持上限 %d", ErrSchemaNewer, v, MaxSchemaVersion)
|
return fmt.Errorf("%w: 库版本 %d > 支持上限 %d", ErrSchemaNewer, v, MaxSchemaVersion)
|
||||||
}
|
}
|
||||||
for i := v; i < MaxSchemaVersion; i++ {
|
for i := v; i < MaxSchemaVersion; i++ {
|
||||||
tx, err := s.db.Begin()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,98 @@ func TestForeignKeysCascade(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOpenDataNoMigrate 维护命令打开方式:不迁移、不做版本守卫(D6,评审 round2 P1-5)。
|
||||||
|
func TestOpenDataNoMigrate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "pn.db")
|
||||||
|
|
||||||
|
// 全新路径:OpenData 不创建 schema
|
||||||
|
s, err := OpenData(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v, err := s.SchemaVersion()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v != 0 {
|
||||||
|
t.Fatalf("OpenData 不应执行迁移,user_version=%d", v)
|
||||||
|
}
|
||||||
|
var one int
|
||||||
|
if err := s.db.QueryRow("SELECT 1 FROM notes LIMIT 1").Scan(&one); err == nil {
|
||||||
|
t.Fatal("OpenData 不应创建表")
|
||||||
|
}
|
||||||
|
s.Close()
|
||||||
|
|
||||||
|
// 更高 schema 版本:OpenData 不被守卫拒绝(升级/回滚场景备份仍可用)
|
||||||
|
s2, err := Open(dbPath, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s2.DB().Exec("PRAGMA user_version = 99"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s2.Close()
|
||||||
|
if _, err := Open(dbPath, false); !errors.Is(err, ErrSchemaNewer) {
|
||||||
|
t.Fatalf("Open 仍应被守卫拒绝,实际 %v", err)
|
||||||
|
}
|
||||||
|
s3, err := OpenData(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenData 不做版本守卫,应放行: %v", err)
|
||||||
|
}
|
||||||
|
s3.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdjacentPublicNote 邻接查询:列表序 pinned DESC, updated_at DESC, id DESC
|
||||||
|
// 的紧邻语义(评审 round2 P1-1:同秒 tie-break 与 pinned 参与排序;仅统计公开)。
|
||||||
|
func TestAdjacentPublicNote(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
mk := func(slug string, at int64, pinned bool, status string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := s.CreateNote(&Note{Slug: slug, Title: slug, Status: status,
|
||||||
|
Tags: []string{}, Pinned: pinned, CreatedAt: at, UpdatedAt: at}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mk("a", 100, false, "public") // id=1
|
||||||
|
mk("b", 100, false, "public") // id=2(与 a 同秒,列表中排在 a 之前)
|
||||||
|
mk("c", 80, false, "public") // id=3
|
||||||
|
mk("p", 50, true, "public") // id=4 置顶 → 列表首
|
||||||
|
mk("priv", 200, false, "private") // 更新的私有笔记:不得出现在邻接中
|
||||||
|
|
||||||
|
get := func(slug string) *Note {
|
||||||
|
n, err := s.GetNoteBySlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
// 公开列表序:p > b > a > c(priv 被过滤)
|
||||||
|
cases := []struct{ slug, prev, next string }{
|
||||||
|
{"p", "", "b"}, // 置顶首篇
|
||||||
|
{"b", "p", "a"},
|
||||||
|
{"a", "b", "c"}, // a 与 b 同秒:prev 应取同秒中紧邻的 b(id 更大),而非跳过
|
||||||
|
{"c", "a", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
prevSlug, _, err := s.AdjacentPublicNote(get(tc.slug), "prev")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s prev: %v", tc.slug, err)
|
||||||
|
}
|
||||||
|
nextSlug, _, err := s.AdjacentPublicNote(get(tc.slug), "next")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s next: %v", tc.slug, err)
|
||||||
|
}
|
||||||
|
if prevSlug != tc.prev || nextSlug != tc.next {
|
||||||
|
t.Errorf("%s: 期望 prev=%q next=%q,实际 prev=%q next=%q",
|
||||||
|
tc.slug, tc.prev, tc.next, prevSlug, nextSlug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, _, err := s.AdjacentPublicNote(get("a"), "bad"); err == nil {
|
||||||
|
t.Error("非法 dir 应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func openTestStore(t *testing.T) *Store {
|
func openTestStore(t *testing.T) *Store {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, err := Open(filepath.Join(t.TempDir(), "test.db"), false)
|
s, err := Open(filepath.Join(t.TempDir(), "test.db"), false)
|
||||||
|
|||||||
Reference in New Issue
Block a user