后端基础层:模块定义、配置解析、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 同源校验
This commit is contained in:
2026-09-08 08:14:12 +08:00
parent 685f628e26
commit 247e88c4fb
17 changed files with 1786 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
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, '\''))
}