- 编辑页标题改 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,公开)与程序生成的示例图片
137 lines
4.0 KiB
Go
137 lines
4.0 KiB
Go
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 检视用)。
|
|
// 站点 Logo 引用的图片虽无笔记引用仍受保护,不视为孤儿。
|
|
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)
|
|
AND i.id <> ?
|
|
ORDER BY i.created_at DESC`, s.logoImageID())
|
|
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
|
|
}
|