Files
pure-note/internal/store/maint.go
T
wangairnan 247e88c4fb 后端基础层:模块定义、配置解析、SQLite 存储层、Argon2id 认证与 Markdown 渲染
- internal/config:serve/init/backup/gc 子命令参数解析(--dev 强制 loopback 守卫)
- internal/store:user_version 版本化迁移(仅追加式 + 越界拒启 + --allow-newer)、
  笔记/图片/引用/会话/设置 DAO、并集可见性查询、VACUUM INTO 在线备份、
  回收站 30 天 + 孤儿图 7 天宽限 gc
- internal/auth:Argon2id PHC 串(m=19456,t=2,p=1)、256bit token 与 SHA-256 摘要
- internal/markdown:goldmark(默认转义)+ bluemonday 双保险,摘要纯文本提取
- internal/middleware:安全头(CSP/HSTS/nosniff 等)、错误日志、
  有界令牌桶限流(per-IP 桶上限 + TTL 逐出)、Origin/Referer 同源校验
2026-09-08 08:14:12 +08:00

135 lines
3.7 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"
"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
}
for _, en := range rep.ExpiredNotes {
if err := s.DeleteNoteForever(en.ID); err != nil {
return nil, fmt.Errorf("物理删除笔记 %d: %w", en.ID, err)
}
}
for _, img := range rep.OrphanImages {
if err := s.DeleteImage(img.ID); err != nil {
return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err)
}
}
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)
}
// VACUUM INTO 不接受参数绑定,路径经单引号转义(无参数化通道时的最小注入面)。
if _, err := s.db.Exec("VACUUM INTO " + escapeSQLString(destPath)); err != nil {
return err
}
// 备份含全部私钥内容:强制 0600(§10.3)
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, '\''))
}