Files
pure-note/internal/store/maint.go
T
wangairnan 132c4a1ba2 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)。
2026-09-08 17:32:47 +08:00

156 lines
4.6 KiB
Go
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.
package store
import (
"fmt"
"os"
"syscall"
"time"
)
// GCReport gc 结果报告(dry-run 与 commit 共用)。
type GCReport struct {
ExpiredNotes []ExpiredNote `json:"expired_notes"`
OrphanImages []Image `json:"orphan_images"`
ExpiredSessons int64 `json:"expired_sessions"`
Committed bool `json:"committed"`
}
// ExpiredNote 待物理删除的回收站笔记。
type ExpiredNote struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
DeletedAt int64 `json:"deleted_at"`
}
// TrashTTL 回收站保留期(§6.3:30 天)。
const TrashTTL = 30 * 24 * time.Hour
// OrphanGrace 孤儿图片宽限期(§6.3:7 天,防误删编辑中刚上传的图)。
const OrphanGrace = 7 * 24 * time.Hour
// GC 清理:回收站过期笔记物理删除、孤儿图片清除、过期会话清理。
// dryRun=true 时只输出计划不改数据。
func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
rep := &GCReport{Committed: !dryRun}
noteCutoff := now.Add(-TrashTTL).Unix()
rows, err := s.db.Query(
`SELECT id, slug, deleted_at FROM notes WHERE deleted_at IS NOT NULL AND deleted_at < ? ORDER BY deleted_at`,
noteCutoff)
if err != nil {
return nil, err
}
for rows.Next() {
var en ExpiredNote
if err := rows.Scan(&en.ID, &en.Slug, &en.DeletedAt); err != nil {
rows.Close()
return nil, err
}
rep.ExpiredNotes = append(rep.ExpiredNotes, en)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
// 孤儿图片:0 引用且超过宽限期
imageCutoff := now.Add(-OrphanGrace).Unix()
orphanRows, err := s.db.Query(`
SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i
WHERE i.created_at < ? AND NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id)
ORDER BY i.created_at`, imageCutoff)
if err != nil {
return nil, err
}
for orphanRows.Next() {
var img Image
if err := orphanRows.Scan(&img.ID, &img.SHA256, &img.MIME, &img.Size, &img.CreatedAt); err != nil {
orphanRows.Close()
return nil, err
}
rep.OrphanImages = append(rep.OrphanImages, img)
}
if err := orphanRows.Err(); err != nil {
orphanRows.Close()
return nil, err
}
orphanRows.Close()
if dryRun {
// 会话计数(不改数据)
var cnt int64
if err := s.db.QueryRow(`SELECT COUNT(*) FROM sessions WHERE expires_at < ?`, now.Unix()).Scan(&cnt); err != nil {
return nil, err
}
rep.ExpiredSessons = cnt
return rep, nil
}
// 条件复查删除(防 TOCTOU:SELECT 与 DELETE 之间管理员恢复笔记/图片被
// 重新引用时,不删活数据;评审 round2 P2-6)
for _, en := range rep.ExpiredNotes {
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)
}
if rows, _ := res.RowsAffected(); rows == 0 {
continue // 期间已被恢复,跳过
}
}
for _, img := range rep.OrphanImages {
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)
}
if rows, _ := res.RowsAffected(); rows == 0 {
continue // 期间已被引用,跳过
}
}
n, err := s.DeleteExpiredSessions(now.Unix())
if err != nil {
return nil, err
}
rep.ExpiredSessons = n
return rep, nil
}
// Backup 在线备份:VACUUM INTO 一致快照(WAL 下与 serve 并发安全,§10.3)。
func (s *Store) Backup(destPath string) error {
if _, err := os.Stat(destPath); err == nil {
return fmt.Errorf("目标文件已存在: %s", destPath)
}
// 备份含全部私密内容:临时收紧 umask,确保 VACUUM INTO 创建即 0600
// (否则以默认 umask(常 0644)创建、事后 chmod 存在暴露窗口,评审 round2 P2-7)。
// backup 为 CLI 单线程进程,进程级 umask 无并发副作用。
old := syscall.Umask(0o077)
// VACUUM INTO 不接受参数绑定,路径经单引号转义(无参数化通道时的最小注入面)。
_, err := s.db.Exec("VACUUM INTO " + escapeSQLString(destPath))
syscall.Umask(old)
if err != nil {
return err
}
// 双保险:显式收紧(幂等)
if err := os.Chmod(destPath, 0o600); err != nil {
return err
}
return nil
}
func escapeSQLString(s string) string {
out := make([]byte, 0, len(s)+2)
out = append(out, '\'')
for i := 0; i < len(s); i++ {
if s[i] == '\'' {
out = append(out, '\'', '\'')
} else {
out = append(out, s[i])
}
}
return string(append(out, '\''))
}