后端基础层:模块定义、配置解析、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 (
"database/sql"
"errors"
"regexp"
)
// Image 图片元信息(不含 BLOB)。
type Image struct {
ID int64 `json:"id"`
SHA256 string `json:"sha256"`
MIME string `json:"mime"`
Size int `json:"size"`
CreatedAt int64 `json:"created_at"`
}
// UpsertImage 按 sha256 去重入库,返回图片 id。
func (s *Store) UpsertImage(sha256Hex, mime string, size int, data []byte, createdAt int64) (int64, error) {
if _, err := s.db.Exec(
`INSERT OR IGNORE INTO images (sha256, mime, size, data, created_at) VALUES (?, ?, ?, ?, ?)`,
sha256Hex, mime, size, data, createdAt); err != nil {
return 0, err
}
var id int64
err := s.db.QueryRow(`SELECT id FROM images WHERE sha256=?`, sha256Hex).Scan(&id)
return id, err
}
// GetImageMeta 取图片元信息。
func (s *Store) GetImageMeta(id int64) (*Image, error) {
row := s.db.QueryRow(`SELECT id, sha256, mime, size, created_at FROM images WHERE id=?`, id)
var img Image
err := row.Scan(&img.ID, &img.SHA256, &img.MIME, &img.Size, &img.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return &img, err
}
// GetImageData 取图片字节。
func (s *Store) GetImageData(id int64) ([]byte, error) {
var data []byte
err := s.db.QueryRow(`SELECT data FROM images WHERE id=?`, id).Scan(&data)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return data, err
}
// ImageIsPublic 并集语义(§6.2):任一引用笔记 public 且未删除 ⇒ 匿名可读。
func (s *Store) ImageIsPublic(id int64) (bool, error) {
var one int
err := s.db.QueryRow(`
SELECT 1 FROM image_refs r JOIN notes n ON n.id = r.note_id
WHERE r.image_id = ? AND n.status='public' AND n.deleted_at IS NULL
LIMIT 1`, id).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
// imageRefRe 扫描 content 中对 /api/images/{id} 的引用(§6.2 引用维护)。
var imageRefRe = regexp.MustCompile(`/api/images/(\d+)`)
// RebuildImageRefs 在**同一事务**内重建笔记的图片引用:
// 删除该笔记全部 refs → 正则扫描 content → 重建(§6.2)。
func (s *Store) RebuildImageRefs(noteID int64, content string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := rebuildRefsTx(tx, noteID, content); err != nil {
return err
}
return tx.Commit()
}
func rebuildRefsTx(tx *sql.Tx, noteID int64, content string) error {
if _, err := tx.Exec(`DELETE FROM image_refs WHERE note_id=?`, noteID); err != nil {
return err
}
seen := map[int64]bool{}
for _, m := range imageRefRe.FindAllStringSubmatch(content, -1) {
var imgID int64
if _, err := fmtSscanInt(m[1], &imgID); err != nil {
continue
}
if seen[imgID] {
continue
}
seen[imgID] = true
// 图片不存在时忽略(引用悬空无害)
if _, err := tx.Exec(`INSERT OR IGNORE INTO image_refs (image_id, note_id) VALUES (?, ?)`, imgID, noteID); err != nil {
return err
}
}
return nil
}
// ListOrphanImages 零引用图片清单(GET /api/admin/images?orphan=1 与 gc 检视用)。
func (s *Store) ListOrphanImages() ([]Image, error) {
rows, err := s.db.Query(`
SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i
WHERE NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id)
ORDER BY i.created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Image{}
for rows.Next() {
var img Image
if err := rows.Scan(&img.ID, &img.SHA256, &img.MIME, &img.Size, &img.CreatedAt); err != nil {
return nil, err
}
out = append(out, img)
}
return out, rows.Err()
}
// DeleteImage 物理删除单张图片(refs 级联)。
func (s *Store) DeleteImage(id int64) error {
res, err := s.db.Exec(`DELETE FROM images WHERE id=?`, id)
if err != nil {
return err
}
if rows, _ := res.RowsAffected(); rows == 0 {
return ErrNotFound
}
return nil
}
+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, '\''))
}
+361
View File
@@ -0,0 +1,361 @@
package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
)
// Note 笔记实体。DeletedAt 非 nil 表示处于回收站(软删除)。
type Note struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Summary string `json:"summary"`
Content string `json:"content,omitempty"`
Status string `json:"status"`
Tags []string `json:"tags"`
Pinned bool `json:"pinned"`
DeletedAt *int64 `json:"deleted_at,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// ErrNotFound 统一的「不存在」错误。
var ErrNotFound = errors.New("not found")
const noteColumns = "id, slug, title, summary, content, status, tags, pinned, deleted_at, created_at, updated_at"
func scanNote(scan func(dest ...any) error) (*Note, error) {
var n Note
var tagsJSON string
var pinned int
var content string
if err := scan(&n.ID, &n.Slug, &n.Title, &n.Summary, &content, &n.Status, &tagsJSON, &pinned, &n.DeletedAt, &n.CreatedAt, &n.UpdatedAt); err != nil {
return nil, err
}
n.Content = content
n.Pinned = pinned != 0
if err := json.Unmarshal([]byte(tagsJSON), &n.Tags); err != nil {
n.Tags = []string{}
}
if n.Tags == nil {
n.Tags = []string{}
}
return &n, nil
}
// CreateNote 新建笔记,返回 id。
func (s *Store) CreateNote(n *Note) (int64, error) {
tags, err := marshalTags(n.Tags)
if err != nil {
return 0, err
}
res, err := s.db.Exec(
`INSERT INTO notes (slug, title, summary, content, status, tags, pinned, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.CreatedAt, n.UpdatedAt)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// UpdateNote 更新笔记内容字段(不含 slug 冲突裁决,见 httpapi 层)。
func (s *Store) UpdateNote(n *Note) error {
tags, err := marshalTags(n.Tags)
if err != nil {
return err
}
res, err := s.db.Exec(
`UPDATE notes SET title=?, summary=?, content=?, status=?, tags=?, pinned=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.UpdatedAt, n.ID)
if err != nil {
return err
}
if rows, _ := res.RowsAffected(); rows == 0 {
return ErrNotFound
}
return nil
}
// UpdateNoteSlug 修改 slug(仅正常态笔记)。
func (s *Store) UpdateNoteSlug(id int64, slug string, updatedAt int64) error {
res, err := s.db.Exec(`UPDATE notes SET slug=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, slug, updatedAt, id)
if err != nil {
return err
}
if rows, _ := res.RowsAffected(); rows == 0 {
return ErrNotFound
}
return nil
}
// SoftDeleteNote 置 deleted_at(回收站)。refs 不动(§6.2)。
func (s *Store) SoftDeleteNote(id int64, at int64) error {
res, err := s.db.Exec(`UPDATE notes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, at, id)
if err != nil {
return err
}
if rows, _ := res.RowsAffected(); rows == 0 {
return ErrNotFound
}
return nil
}
// RestoreNote 清空 deleted_at。
func (s *Store) RestoreNote(id int64) error {
res, err := s.db.Exec(`UPDATE notes SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL`, id)
if err != nil {
return err
}
if rows, _ := res.RowsAffected(); rows == 0 {
return ErrNotFound
}
return nil
}
// GetNoteByID 按 id 取(含回收站)。
func (s *Store) GetNoteByID(id int64) (*Note, error) {
row := s.db.QueryRow(`SELECT `+noteColumns+` FROM notes WHERE id=?`, id)
n, err := scanNote(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return n, err
}
// GetNoteBySlug 按 slug 取(含回收站;可见性裁决在调用方)。
func (s *Store) GetNoteBySlug(slug string) (*Note, error) {
row := s.db.QueryRow(`SELECT `+noteColumns+` FROM notes WHERE slug=?`, slug)
n, err := scanNote(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return n, err
}
// SlugExists slug 是否已被占用(**含回收站**,§6.2:回收期内 slug 仍被占用)。
// excludeID 用于更新场景排除自身;传 0 表示不排除。
func (s *Store) SlugExists(slug string, excludeID int64) (bool, error) {
var one int
err := s.db.QueryRow(`SELECT 1 FROM notes WHERE slug=? AND id<>? LIMIT 1`, slug, excludeID).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
// PublicNoteFilter 可见性过滤的统一谓词(§9.1-T10 单一可信点)。
const PublicNoteFilter = "status='public' AND deleted_at IS NULL"
// ListPublicNotes 公开笔记分页列表(不含全文)。tag 非空时按标签过滤。
func (s *Store) ListPublicNotes(page, pageSize int, tag string) ([]Note, int, error) {
where := PublicNoteFilter
args := []any{}
if tag != "" {
// json_each 参数化过滤,无拼接注入面。
where += ` AND EXISTS (SELECT 1 FROM json_each(notes.tags) je WHERE je.value = ?)`
args = append(args, tag)
}
var total int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM notes n WHERE `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
q := `SELECT n.id, n.slug, n.title, n.summary, '' AS content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at
FROM notes n WHERE ` + where + `
ORDER BY n.pinned DESC, n.updated_at DESC, n.id DESC LIMIT ? OFFSET ?`
rows, err := s.db.Query(q, append(args, pageSize, (page-1)*pageSize)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []Note
for rows.Next() {
n, err := scanNote(rows.Scan)
if err != nil {
return nil, 0, err
}
out = append(out, *n)
}
return out, total, rows.Err()
}
// AdjacentPublicNote 返回公开序列中与 n 相邻的上一篇/下一篇
// (按列表序 updated_at DESC, id DESC)。仅返回 slug 与 title。
func (s *Store) AdjacentPublicNote(n *Note, dir string) (slug, title string, err error) {
var q string
switch dir {
case "prev": // 列表中更早的一篇
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
AND (updated_at > ? OR (updated_at = ? AND id < ?))
ORDER BY updated_at ASC, id DESC LIMIT 1`
case "next": // 列表中更新的一篇
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
AND (updated_at < ? OR (updated_at = ? AND id > ?))
ORDER BY updated_at DESC, id ASC LIMIT 1`
default:
return "", "", fmt.Errorf("dir 必须为 prev|next")
}
row := s.db.QueryRow(q, n.UpdatedAt, n.UpdatedAt, n.ID)
if err := row.Scan(&slug, &title); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", "", nil
}
return "", "", err
}
return slug, title, nil
}
// ListPublicNotesFull 公开笔记列表(**含全文**,RSS/渲染用),上限 limit 条。
func (s *Store) ListPublicNotesFull(limit int) ([]Note, error) {
q := `SELECT ` + noteColumns + ` FROM notes WHERE ` + PublicNoteFilter + `
ORDER BY updated_at DESC, id DESC LIMIT ?`
rows, err := s.db.Query(q, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Note{}
for rows.Next() {
n, err := scanNote(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *n)
}
return out, rows.Err()
}
// ListAdminNotes 全部正常笔记(含私有,不含回收站),不含全文。
func (s *Store) ListAdminNotes() ([]Note, error) {
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at
FROM notes WHERE deleted_at IS NULL
ORDER BY pinned DESC, updated_at DESC, id DESC`
return s.queryNotes(q)
}
// ListTrash 回收站列表(软删除中),不含全文。
func (s *Store) ListTrash() ([]Note, error) {
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at
FROM notes WHERE deleted_at IS NOT NULL
ORDER BY deleted_at DESC`
return s.queryNotes(q)
}
// ListExpiredTrash 过期待物理删除的笔记 id(gc 用)。
func (s *Store) ListExpiredTrash(before int64) ([]int64, error) {
rows, err := s.db.Query(`SELECT id FROM notes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, before)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// DeleteNoteForever 物理删除(refs 级联,需 foreign_keys=1)。
func (s *Store) DeleteNoteForever(id int64) error {
res, err := s.db.Exec(`DELETE FROM notes WHERE id=?`, id)
if err != nil {
return err
}
if rows, _ := res.RowsAffected(); rows == 0 {
return ErrNotFound
}
return nil
}
// TagCount 标签聚合项。
type TagCount struct {
Name string `json:"name"`
Count int `json:"count"`
}
// PublicTags 仅统计公开且未删除笔记的标签(§7.1 /api/tags)。
func (s *Store) PublicTags() ([]TagCount, error) {
rows, err := s.db.Query(`
SELECT je.value AS tag, COUNT(*) AS cnt
FROM notes n, json_each(n.tags) je
WHERE ` + PublicNoteFilter + `
GROUP BY je.value
ORDER BY cnt DESC, tag ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TagCount
for rows.Next() {
var tc TagCount
if err := rows.Scan(&tc.Name, &tc.Count); err != nil {
return nil, err
}
out = append(out, tc)
}
return out, rows.Err()
}
func (s *Store) queryNotes(q string, args ...any) ([]Note, error) {
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Note{}
for rows.Next() {
n, err := scanNote(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *n)
}
return out, rows.Err()
}
func marshalTags(tags []string) (string, error) {
if tags == nil {
tags = []string{}
}
b, err := json.Marshal(tags)
if err != nil {
return "", err
}
return string(b), nil
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// NormalizeTags 清洗标签:去空白、去空、去重、上限 20 个、单个 ≤64 字符。
func NormalizeTags(tags []string) []string {
seen := map[string]bool{}
out := []string{}
for _, t := range tags {
t = strings.TrimSpace(t)
if t == "" || seen[t] {
continue
}
if len([]rune(t)) > 64 {
t = string([]rune(t)[:64])
}
seen[t] = true
out = append(out, t)
if len(out) >= 20 {
break
}
}
return out
}
+78
View File
@@ -0,0 +1,78 @@
package store
import (
"database/sql"
"errors"
"strconv"
)
func fmtSscanInt(s string, out *int64) (int, error) {
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, err
}
*out = v
return 1, nil
}
// Session 会话行(token 明文不入库,只存 SHA-256)。
type Session struct {
TokenHash string
CSRFToken string
CreatedAt int64
ExpiresAt int64
}
// CreateSession 插入会话行。
func (s *Store) CreateSession(tokenHash, csrfToken string, createdAt, expiresAt int64) error {
_, err := s.db.Exec(
`INSERT INTO sessions (token_hash, csrf_token, created_at, expires_at) VALUES (?, ?, ?, ?)`,
tokenHash, csrfToken, createdAt, expiresAt)
return err
}
// RotateSession 重建会话行:新 token、**csrf_token 保持不变**(§7.3-5)、新过期时间。
// 原子替换,避免窗口期内两行并存。
func (s *Store) RotateSession(oldTokenHash, newTokenHash, csrfToken string, createdAt, expiresAt int64) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM sessions WHERE token_hash=?`, oldTokenHash); err != nil {
return err
}
if _, err := tx.Exec(
`INSERT INTO sessions (token_hash, csrf_token, created_at, expires_at) VALUES (?, ?, ?, ?)`,
newTokenHash, csrfToken, createdAt, expiresAt); err != nil {
return err
}
return tx.Commit()
}
// GetSession 按 token 摘要取会话(含已过期行;过期判定在调用方)。
func (s *Store) GetSession(tokenHash string) (*Session, error) {
row := s.db.QueryRow(
`SELECT token_hash, csrf_token, created_at, expires_at FROM sessions WHERE token_hash=?`, tokenHash)
var sess Session
err := row.Scan(&sess.TokenHash, &sess.CSRFToken, &sess.CreatedAt, &sess.ExpiresAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return &sess, err
}
// DeleteSession 删除会话行(登出/轮换)。
func (s *Store) DeleteSession(tokenHash string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash=?`, tokenHash)
return err
}
// DeleteExpiredSessions 清理过期会话,返回删除行数。
func (s *Store) DeleteExpiredSessions(now int64) (int64, error) {
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
+70
View File
@@ -0,0 +1,70 @@
package store
import (
"database/sql"
"errors"
"fmt"
)
// Settings 键白名单(§7.1 SettingsDTO 同源;admin_password_hash 永不进入 API 响应)。
const (
KeyAdminPasswordHash = "admin_password_hash"
KeySiteTitle = "site_title"
KeySiteDesc = "site_desc"
KeyPageSize = "page_size"
)
// GetSetting 读取单个设置。
func (s *Store) GetSetting(key string) (string, bool, error) {
var v string
err := s.db.QueryRow(`SELECT value FROM settings WHERE key=?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
return v, err == nil, err
}
// SetSetting 写入单个设置(INSERT OR REPLACE)。
func (s *Store) SetSetting(key, value string) error {
_, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value)
return err
}
// SiteSettings 对外的站点设置白名单视图。
type SiteSettings struct {
SiteTitle string `json:"site_title"`
SiteDesc string `json:"site_desc"`
PageSize int `json:"page_size"`
}
// Defaults,未初始化时兜底。
const (
DefaultSiteTitle = "Pure Note"
DefaultSiteDesc = ""
DefaultPageSize = 10
)
// GetSiteSettings 读取站点设置(白名单三键,带默认值)。
func (s *Store) GetSiteSettings() (*SiteSettings, error) {
ss := &SiteSettings{SiteTitle: DefaultSiteTitle, SiteDesc: DefaultSiteDesc, PageSize: DefaultPageSize}
if v, ok, err := s.GetSetting(KeySiteTitle); err != nil {
return nil, err
} else if ok && v != "" {
ss.SiteTitle = v
}
if v, ok, err := s.GetSetting(KeySiteDesc); err != nil {
return nil, err
} else if ok {
ss.SiteDesc = v
}
if v, ok, err := s.GetSetting(KeyPageSize); err != nil {
return nil, err
} else if ok {
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n >= 1 && n <= 100 {
ss.PageSize = n
}
}
return ss, nil
}
+154
View File
@@ -0,0 +1,154 @@
// Package store SQLite 打开、版本化迁移与全部数据访问。
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"path/filepath"
"time"
_ "modernc.org/sqlite"
)
// bgCtx DAO 内部使用的后台 context(个人规模查询均为快速查询)。
var bgCtx = context.Background()
// ErrSchemaNewer 数据库 schema 版本超出代码支持范围(§10.4 启动守卫)。
var ErrSchemaNewer = errors.New("数据库 schema 版本高于本程序支持的最高版本,拒绝启动(可用 --allow-newer 显式放行)")
// migrations 仅追加式:禁止删列/重命名/改类型(§6.2)。
var migrations = []string{
// v1: 初始 schema(§6.1 DDL)
`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'private'
CHECK (status IN ('public','private')),
tags TEXT NOT NULL DEFAULT '[]',
pinned INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notes_public
ON notes (status, pinned, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_notes_deleted ON notes (deleted_at);
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sha256 TEXT NOT NULL UNIQUE,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
data BLOB NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS image_refs (
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
PRIMARY KEY (image_id, note_id)
);
CREATE INDEX IF NOT EXISTS idx_image_refs_note ON image_refs (note_id);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
csrf_token TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions (expires_at);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`,
}
// MaxSchemaVersion 代码支持的最高 schema 版本。
var MaxSchemaVersion = len(migrations)
// Store 封装 *sql.DB;单连接串行写(§6.2 并发策略)。
type Store struct {
db *sql.DB
}
// Open 打开数据库并执行迁移。dbPath 为文件绝对/相对路径。
func Open(dbPath string, allowNewer bool) (*Store, error) {
// SQLite URI 不接受相对路径
abs, err := filepath.Abs(dbPath)
if err != nil {
return nil, err
}
dsn := url.URL{
Scheme: "file",
Path: abs,
RawQuery: "_pragma=journal_mode(WAL)" +
"&_pragma=busy_timeout(5000)" +
"&_pragma=foreign_keys(1)" +
"&_pragma=synchronous(NORMAL)",
}
db, err := sql.Open("sqlite", dsn.String())
if err != nil {
return nil, err
}
// 单写者串行化(§6.2)
db.SetMaxOpenConns(1)
s := &Store{db: db}
if err := s.migrate(allowNewer); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) migrate(allowNewer bool) error {
var v int
if err := s.db.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
return err
}
if v > MaxSchemaVersion && !allowNewer {
return fmt.Errorf("%w: 库版本 %d > 支持上限 %d", ErrSchemaNewer, v, MaxSchemaVersion)
}
for i := v; i < MaxSchemaVersion; i++ {
tx, err := s.db.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(migrations[i]); err != nil {
tx.Rollback()
return fmt.Errorf("执行迁移 v%d 失败: %w", i+1, err)
}
// PRAGMA 不能参数化,i 为内部 int 常量,无注入面。
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
// SchemaVersion 返回当前库版本。
func (s *Store) SchemaVersion() (int, error) {
var v int
err := s.db.QueryRow("PRAGMA user_version").Scan(&v)
return v, err
}
// Close 关闭连接。
func (s *Store) Close() error { return s.db.Close() }
// DB 暴露底层连接(仅备份等维护路径使用)。
func (s *Store) DB() *sql.DB { return s.db }
func nowUnix() int64 { return time.Now().Unix() }
+122
View File
@@ -0,0 +1,122 @@
package store
import (
"errors"
"path/filepath"
"testing"
)
// TestMigrationsFromEmpty 空库 → 最新版本;重复打开幂等(§13 迁移组)。
func TestMigrationsFromEmpty(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "pure-note.db")
s1, err := Open(dbPath, false)
if err != nil {
t.Fatalf("首次打开失败: %v", err)
}
v1, err := s1.SchemaVersion()
if err != nil {
t.Fatal(err)
}
if v1 != MaxSchemaVersion {
t.Fatalf("期望版本 %d 实际 %d", MaxSchemaVersion, v1)
}
s1.Close()
// 重开:幂等,不再执行迁移
s2, err := Open(dbPath, false)
if err != nil {
t.Fatalf("二次打开失败: %v", err)
}
defer s2.Close()
v2, err := s2.SchemaVersion()
if err != nil {
t.Fatal(err)
}
if v2 != v1 {
t.Fatalf("迁移不幂等: %d → %d", v1, v2)
}
}
// TestMigrationNewerRejected user_version 越界拒绝启动;--allow-newer 放行(§10.4)。
func TestMigrationNewerRejected(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "pure-note.db")
s1, err := Open(dbPath, false)
if err != nil {
t.Fatal(err)
}
if _, err := s1.DB().Exec("PRAGMA user_version = 99"); err != nil {
t.Fatal(err)
}
s1.Close()
_, err = Open(dbPath, false)
if !errors.Is(err, ErrSchemaNewer) {
t.Fatalf("期望 ErrSchemaNewer,实际 %v", err)
}
s2, err := Open(dbPath, true) // --allow-newer
if err != nil {
t.Fatalf("allow-newer 应放行: %v", err)
}
defer s2.Close()
}
// TestForeignKeysCascade 外键级联生效(image_refs 依赖 foreign_keys=1)。
func TestForeignKeysCascade(t *testing.T) {
s := openTestStore(t)
nid, err := s.CreateNote(&Note{Slug: "casc", Title: "级联", Status: "public",
Tags: []string{}, CreatedAt: 1, UpdatedAt: 1})
if err != nil {
t.Fatal(err)
}
imgID, err := s.UpsertImage("aa", "image/png", 1, []byte{1}, 1)
if err != nil {
t.Fatal(err)
}
if err := s.RebuildImageRefs(nid, "![x](/api/images/"+itoa64(imgID)+")"); err != nil {
t.Fatal(err)
}
if err := s.DeleteNoteForever(nid); err != nil {
t.Fatal(err)
}
// 级联后图片变孤儿
orphans, err := s.ListOrphanImages()
if err != nil {
t.Fatal(err)
}
found := false
for _, o := range orphans {
if o.ID == imgID {
found = true
}
}
if !found {
t.Fatal("物理删除笔记后其引用关系应被级联删除(图片转为孤儿)")
}
}
func openTestStore(t *testing.T) *Store {
t.Helper()
s, err := Open(filepath.Join(t.TempDir(), "test.db"), false)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s
}
func itoa64(v int64) string {
if v == 0 {
return "0"
}
var b [20]byte
i := len(b)
for v > 0 {
i--
b[i] = byte('0' + v%10)
v /= 10
}
return string(b[i:])
}