- 编辑页标题改 filled 变体;标签改 tags 选择器(可勾选既有/输入新建) - 笔记发布日期可自选(schema v2 新增 published_at 并回填),前台展示发布日期 - 登录页移除「请输入管理密码以继续」「连续失败将被暂时锁定」文案 - 站点设置每页条数收敛为 10/20/30/50 选择器,后端白名单同源校验 - 站点设置两卡片宽屏左右/窄屏上下;修改密码按钮改常规大小 - 后台各页顶栏固定高度,切换页面不再抖动 - 站点 Logo:settings 新增 site_logo(站内路径),gc/孤儿清单豁免 Logo 图片, 博客 header 站点名前展示,设置页支持上传/更换/清除 - 回收站右上角一键清空(DELETE /api/admin/trash,含确认弹窗) - pn init 写入 Markdown 语法示例文档(slug welcome,公开)与程序生成的示例图片
159 lines
4.8 KiB
Go
159 lines
4.8 KiB
Go
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 引用且超过宽限期;站点 Logo 引用的图片受保护
|
||
imageCutoff := now.Add(-OrphanGrace).Unix()
|
||
logoID := s.logoImageID()
|
||
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)
|
||
AND i.id <> ?
|
||
ORDER BY i.created_at`, imageCutoff, logoID)
|
||
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 {
|
||
// 条件复查含 Logo 保护:SELECT 与 DELETE 之间 Logo 可能指向该图
|
||
res, err := s.db.Exec(
|
||
`DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?) AND id<>?`,
|
||
img.ID, img.ID, logoID)
|
||
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, '\''))
|
||
}
|