- 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 同源校验
135 lines
3.7 KiB
Go
135 lines
3.7 KiB
Go
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, '\''))
|
||
}
|