Files
ONE/backend/internal/store/store.go
T
Sakurasan dd2994189a 安全加固 + 结构清理:修注入/串写/竞态,DOMPurify 上线,后端补事务与 handler 测试
后端:
- ORDER BY 白名单(sanitizeOrder)堵住 ?order= SQL 注入,补回归测试
- 登录限速(每 IP 10 次失败/10 分钟 429)、TLS/反代下 Secure cookie、NewAPI 构造器
- Delete/setTags/MergeTags/DeleteTag 包事务;Archive 去 500 篇上限
- 列表接口裁剪:不传 content_md,长文 content_html 截 600,新增 content_len;health 探 DB

前端:
- EditorView 路由复用串写修复(RouterView :key + sync watch 回写原文章)
- v-html 出口统一过 DOMPurify(sanitizeHtml),stripTags 改 DOMParser
- 列表竞态防护(Home/Tag/Posts 请求序号)、TagView 分页修复
- 侧栏接口 30s 缓存去重;one:unauthorized 监听器泄漏修复
- 删 styles.css 498 行重复块;移除 tailwind/marked/vue-tsc 死依赖;CommandPalette a11y 语义
2026-09-21 23:59:09 +08:00

1026 lines
29 KiB
Go

// Package store owns the schema and every SQL query. All SQL is written with
// `?` placeholders and re-bound to `$n` when running on PostgreSQL.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"time"
"oneblog/internal/db"
"oneblog/internal/model"
"oneblog/internal/render"
)
func renderHTML(md string) string { return render.Markdown(md) }
func readingMinutes(md string) int { return render.ReadingMinutes(md) }
var ErrNotFound = errors.New("not found")
type Store struct {
db *db.DB
}
func New(d *db.DB) (*Store, error) {
s := &Store{db: d}
if err := s.migrate(); err != nil {
return nil, err
}
return s, nil
}
// Ping 供 /api/health 探活数据库连接。
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
func now() string { return time.Now().UTC().Format(time.RFC3339) }
func (s *Store) migrate() error {
ai := s.db.AutoInc()
stmts := []string{
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS posts (
id %s,
kind TEXT NOT NULL DEFAULT 'long',
title TEXT NOT NULL DEFAULT '',
slug TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
cover_url TEXT NOT NULL DEFAULT '',
content_md TEXT NOT NULL DEFAULT '',
content_html TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
published_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
reading_minutes INTEGER NOT NULL DEFAULT 1
)`, ai),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS tags (
id %s,
name TEXT NOT NULL,
slug TEXT NOT NULL,
color TEXT NOT NULL DEFAULT ''
)`, ai),
`CREATE TABLE IF NOT EXISTS post_tags (
post_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (post_id, tag_id)
)`,
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
)`,
}
for _, q := range stmts {
if _, err := s.db.Exec(s.db.Q(q)); err != nil {
return fmt.Errorf("migrate: %w", err)
}
}
// Lightweight column-add migrations for older databases. SQLite supports
// ADD COLUMN; Postgres doesn't support IF NOT EXISTS on column add, so we
// swallow "already exists" errors either way.
columnAdds := []string{
`ALTER TABLE posts ADD COLUMN cover_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE tags ADD COLUMN color TEXT NOT NULL DEFAULT ''`,
}
for _, q := range columnAdds {
if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") &&
!strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("migrate column: %w", err)
}
}
// Indexes/unique constraints need dialect-specific "IF NOT EXISTS" support.
indexes := []struct{ name, ddl string }{
{"idx_posts_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug)`},
{"idx_posts_feed", `CREATE INDEX IF NOT EXISTS idx_posts_feed ON posts(status, published_at DESC)`},
{"idx_tags_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug)`},
{"idx_post_tags_tag", `CREATE INDEX IF NOT EXISTS idx_post_tags_tag ON post_tags(tag_id)`},
}
for _, ix := range indexes {
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
return fmt.Errorf("migrate index %s: %w", ix.name, err)
}
}
return s.seedSettings()
}
func (s *Store) seedSettings() error {
defs := map[string]string{
"site_title": "ONE · 一个博客",
"site_desc": "长文与短文,同一种节奏。",
"author_name": "ONE",
"author_bio": "写点长的,也写点短的。",
"footer_note": "© ONE · 一个博客",
"icp": "",
"posts_per_page": "10",
}
for k, v := range defs {
if s.db.Dialect == db.Postgres {
_, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
ON CONFLICT (key) DO NOTHING`), k, v)
if err != nil {
return err
}
continue
}
if _, err := s.db.Exec(s.db.Q(`INSERT OR IGNORE INTO settings(key,value) VALUES (?,?)`), k, v); err != nil {
return err
}
}
return nil
}
// ---------- settings ----------
func (s *Store) GetSettings() (model.Settings, error) {
rows, err := s.db.Query(s.db.Q(`SELECT key, value FROM settings`))
if err != nil {
return model.Settings{}, err
}
defer rows.Close()
m := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return model.Settings{}, err
}
m[k] = v
}
return settingsFromMap(m), rows.Err()
}
func settingsFromMap(m map[string]string) model.Settings {
st := model.Settings{
SiteTitle: m["site_title"],
SiteDesc: m["site_desc"],
AuthorName: m["author_name"],
AuthorBio: m["author_bio"],
FooterNote: m["footer_note"],
ICPLicense: m["icp"],
PostsPerPage: 10,
}
// LightSkinID is the new canonical name; fall back to legacy theme_id
// for clients that haven't been updated, and finally to "paper".
st.LightSkinID = m["light_skin_id"]
if st.LightSkinID == "" {
st.LightSkinID = m["theme_id"]
}
if st.LightSkinID == "" {
st.LightSkinID = "paper"
}
// Mirror the value into ThemeID so legacy API consumers still see it
// in the JSON response.
st.ThemeID = st.LightSkinID
if n := atoi(m["posts_per_page"]); n > 0 {
st.PostsPerPage = n
}
return st
}
func atoi(v string) int {
n := 0
for _, r := range v {
if r < '0' || r > '9' {
return 0
}
n = n*10 + int(r-'0')
}
return n
}
// ValidLightSkins is the whitelist of light-side skin ids the API accepts.
// Anything outside this set falls back to "paper" on write.
var ValidLightSkins = map[string]bool{
"paper": true,
"sage": true,
"rose": true,
}
func (s *Store) UpdateSettings(st model.Settings) error {
if st.PostsPerPage <= 0 {
st.PostsPerPage = 10
}
if !ValidLightSkins[st.LightSkinID] {
st.LightSkinID = "paper"
}
sets := map[string]string{
"site_title": st.SiteTitle,
"site_desc": st.SiteDesc,
"author_name": st.AuthorName,
"author_bio": st.AuthorBio,
"footer_note": st.FooterNote,
"icp": st.ICPLicense,
"posts_per_page": fmt.Sprint(st.PostsPerPage),
"light_skin_id": st.LightSkinID,
// Mirror to theme_id so any older client still sees something.
"theme_id": st.LightSkinID,
}
for k, v := range sets {
if s.db.Dialect == db.Postgres {
if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`), k, v); err != nil {
return err
}
continue
}
if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`), k, v); err != nil {
return err
}
}
return nil
}
// ---------- posts ----------
type ListOptions struct {
Kind string
Tag string
Query string
Status string // "" = published only (public), "any" = all (admin)
Page int
Size int
OrderBy string
}
// OrderBy 来自查询参数,必须白名单校验后才能拼进 SQL。
var allowedOrder = map[string]bool{
"published_at desc": true,
"published_at asc": true,
"updated_at desc": true,
"updated_at asc": true,
"created_at desc": true,
"reading_minutes desc": true,
"reading_minutes asc": true,
}
func sanitizeOrder(o string) string {
key := strings.ToLower(strings.Join(strings.Fields(o), " "))
if allowedOrder[key] {
return key
}
return "published_at desc"
}
const postCols = `id, kind, title, slug, summary, cover_url, content_md, content_html, status,
published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)`
// listCols 用于列表/时间线:不传 content_md(前端不用),
// 长文 content_html 只截 600 字符供无摘要时提取纯文本,短文保留全文渲染。
const listCols = `id, kind, title, slug, summary, cover_url,
'' AS content_md,
CASE WHEN kind = 'short' THEN content_html ELSE substr(content_html, 1, 600) END AS content_html,
status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)`
func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) {
var p model.Post
err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
&p.ReadingMinutes, &p.ContentLen)
p.Tags = []string{}
return p, err
}
func (s *Store) List(o ListOptions) (model.Page, error) {
if o.Page < 1 {
o.Page = 1
}
if o.Size < 1 || o.Size > 100 {
o.Size = 10
}
where := []string{}
args := []any{}
if o.Status == "any" {
// admin: no status filter
} else if o.Status != "" {
where = append(where, "status = ?")
args = append(args, o.Status)
} else {
where = append(where, "status = 'published'")
}
if o.Kind != "" {
where = append(where, "kind = ?")
args = append(args, o.Kind)
}
if o.Tag != "" {
where = append(where, `id IN (SELECT pt.post_id FROM post_tags pt JOIN tags t ON t.id = pt.tag_id
WHERE t.slug = ? OR t.name = ?)`)
args = append(args, o.Tag, o.Tag)
}
if o.Query != "" {
like := "%" + strings.ToLower(o.Query) + "%"
where = append(where, `(lower(title) LIKE ? OR lower(summary) LIKE ? OR lower(content_md) LIKE ?)`)
args = append(args, like, like, like)
}
w := ""
if len(where) > 0 {
w = "WHERE " + strings.Join(where, " AND ")
}
order := sanitizeOrder(o.OrderBy)
var total int
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM posts `+w), args...).Scan(&total); err != nil {
return model.Page{}, err
}
q := s.db.Q(fmt.Sprintf(`SELECT %s FROM posts %s ORDER BY %s LIMIT ? OFFSET ?`, listCols, w, order))
rows, err := s.db.Query(q, append(args, o.Size, (o.Page-1)*o.Size)...)
if err != nil {
return model.Page{}, err
}
defer rows.Close()
items := []model.Post{}
for rows.Next() {
p, err := scanPost(rows)
if err != nil {
return model.Page{}, err
}
items = append(items, p)
}
if err := rows.Err(); err != nil {
return model.Page{}, err
}
if err := s.attachTags(items); err != nil {
return model.Page{}, err
}
return model.Page{Items: items, Total: total, Page: o.Page, Size: o.Size}, nil
}
func (s *Store) attachTags(posts []model.Post) error {
if len(posts) == 0 {
return nil
}
ids := make([]any, 0, len(posts))
idx := map[int64]int{}
for i, p := range posts {
ids = append(ids, p.ID)
idx[p.ID] = i
}
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
q := s.db.Q(fmt.Sprintf(`SELECT pt.post_id, t.name FROM post_tags pt
JOIN tags t ON t.id = pt.tag_id WHERE pt.post_id IN (%s) ORDER BY t.name`, ph))
rows, err := s.db.Query(q, ids...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var pid int64
var name string
if err := rows.Scan(&pid, &name); err != nil {
return err
}
if i, ok := idx[pid]; ok {
posts[i].Tags = append(posts[i].Tags, name)
}
}
return rows.Err()
}
func (s *Store) Get(id int64) (model.Post, error) {
var p model.Post
row := s.db.QueryRow(s.db.Q(`SELECT `+postCols+` FROM posts WHERE id = ?`), id)
if err := scanPostInto(row, &p); err != nil {
return p, err
}
items := []model.Post{p}
if err := s.attachTags(items); err != nil {
return p, err
}
return items[0], nil
}
func (s *Store) GetBySlug(slug string) (model.Post, error) {
var p model.Post
row := s.db.QueryRow(s.db.Q(`SELECT `+postCols+` FROM posts WHERE slug = ?`), slug)
if err := scanPostInto(row, &p); err != nil {
return p, err
}
items := []model.Post{p}
if err := s.attachTags(items); err != nil {
return p, err
}
return items[0], nil
}
func scanPostInto(row *sql.Row, p *model.Post) error {
err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
&p.ReadingMinutes, &p.ContentLen)
if err == sql.ErrNoRows {
return ErrNotFound
}
if err != nil {
return err
}
p.Tags = []string{}
return nil
}
// ---------- slug ----------
var slugSep = regexp.MustCompile(`[^\p{L}\p{N}]+`)
func Slugify(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
s = slugSep.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
return s
}
func (s *Store) uniqueSlug(base string, excludeID int64) string {
base = Slugify(base)
if base == "" {
base = "post"
}
candidate := base
for i := 2; ; i++ {
var id int64
err := s.db.QueryRow(s.db.Q(`SELECT id FROM posts WHERE slug = ? AND id <> ?`), candidate, excludeID).Scan(&id)
if err == sql.ErrNoRows {
return candidate
}
if err != nil {
return fmt.Sprintf("%s-%d", base, time.Now().Unix())
}
candidate = fmt.Sprintf("%s-%d", base, i)
}
}
// ---------- write ----------
func (s *Store) Create(in model.PostInput) (model.Post, error) {
p := model.Post{
Kind: in.Kind,
Title: strings.TrimSpace(in.Title),
Slug: in.Slug,
Status: in.Status,
CoverURL: strings.TrimSpace(in.CoverURL),
Tags: []string{},
}
if p.Kind == "" {
p.Kind = model.KindLong
}
if p.Status == "" {
p.Status = model.StatusDraft
}
if p.Slug == "" {
p.Slug = Slugify(p.Title)
}
if p.Slug == "" {
// Titles without any latin characters (typical for short notes) get a
// date-based slug instead of colliding on "post", "post-2", ...
p.Slug = "s-" + time.Now().UTC().Format("20060102-150405")
}
p.Slug = s.uniqueSlug(p.Slug, 0)
// Short posts have no visible title, but archive and tag listings still
// need something to index them by.
if p.Kind == model.KindShort && p.Title == "" {
p.Title = render.TitleFromMarkdown(in.ContentMd)
}
p.Summary = strings.TrimSpace(in.Summary)
p.ContentMd = in.ContentMd
p.ContentHTML = renderHTML(in.ContentMd)
p.PublishedAt = in.PublishedAt
p.CreatedAt = now()
p.UpdatedAt = p.CreatedAt
if p.PublishedAt == "" {
p.PublishedAt = p.CreatedAt
}
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
p.ReadingMinutes = *in.ReadingMinutes
} else {
p.ReadingMinutes = readingMinutes(in.ContentMd)
}
var id int64
q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,cover_url,content_md,content_html,status,
published_at,created_at,updated_at,reading_minutes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes).Scan(&id)
if err != nil {
return p, err
}
} else {
res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes)
if err != nil {
return p, err
}
id, err = res.LastInsertId()
if err != nil {
return p, err
}
}
p.ID = id
if err := s.setTags(id, in.Tags); err != nil {
return p, err
}
return s.Get(id)
}
func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
cur, err := s.Get(id)
if err != nil {
return cur, err
}
p := cur
if in.Kind != "" {
p.Kind = in.Kind
}
if in.Title != "" || in.Kind == model.KindShort {
p.Title = strings.TrimSpace(in.Title)
}
if in.Summary != "" {
p.Summary = strings.TrimSpace(in.Summary)
}
if in.ContentMd != "" {
p.ContentMd = in.ContentMd
p.ContentHTML = renderHTML(in.ContentMd)
}
if in.Status != "" {
p.Status = in.Status
}
if in.Slug != "" && in.Slug != cur.Slug {
p.Slug = s.uniqueSlug(in.Slug, id)
}
if in.PublishedAt != "" {
p.PublishedAt = in.PublishedAt
}
// CoverURL is taken verbatim from input. The frontend always sends the
// current value (empty string included), so any update round-trips with
// whatever the user last saved.
p.CoverURL = strings.TrimSpace(in.CoverURL)
p.UpdatedAt = now()
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
p.ReadingMinutes = *in.ReadingMinutes
} else if in.ContentMd != "" {
p.ReadingMinutes = readingMinutes(in.ContentMd)
}
if _, err := s.db.Exec(s.db.Q(`UPDATE posts SET kind=?,title=?,slug=?,summary=?,cover_url=?,content_md=?,
content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=? WHERE id=?`),
p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, p.Status,
p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, id); err != nil {
return p, err
}
if in.Tags != nil {
if err := s.setTags(id, in.Tags); err != nil {
return p, err
}
}
return s.Get(id)
}
// BulkUpdateStatus flips the status of many posts in a single transaction.
func (s *Store) BulkUpdateStatus(ids []int64, status string) (int, error) {
if len(ids) == 0 {
return 0, nil
}
status = NormalizeStatus(status)
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
args := []any{status, now()}
for _, id := range ids {
args = append(args, id)
}
q := s.db.Q(`UPDATE posts SET status=?, updated_at=? WHERE id IN (` + placeholders + `)`)
res, err := s.db.Exec(q, args...)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
// NormalizeStatus coerces an arbitrary input string into a known post status
// value, defaulting to draft when nothing matches.
func NormalizeStatus(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case model.StatusDraft, model.StatusPublished:
return strings.ToLower(strings.TrimSpace(s))
default:
return model.StatusDraft
}
}
func (s *Store) Delete(id int64) error {
return s.tx(func(tx *sql.Tx) error {
if _, err := tx.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), id); err != nil {
return err
}
res, err := tx.Exec(s.db.Q(`DELETE FROM posts WHERE id = ?`), id)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err == nil && n == 0 {
return ErrNotFound
}
return nil
})
}
// tx 把多语句写包进一个事务:中途失败整体回滚,不留半截状态。
func (s *Store) tx(fn func(tx *sql.Tx) error) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
// ---------- tags ----------
func (s *Store) setTags(postID int64, names []string) error {
return s.tx(func(tx *sql.Tx) error {
if _, err := tx.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), postID); err != nil {
return err
}
seen := map[string]bool{}
for _, raw := range names {
name := strings.TrimSpace(raw)
if name == "" || seen[name] {
continue
}
seen[name] = true
tagID, err := s.upsertTagOn(tx, name)
if err != nil {
return err
}
if _, err := tx.Exec(s.db.Q(`INSERT INTO post_tags(post_id, tag_id) VALUES (?,?)`), postID, tagID); err != nil {
return err
}
}
return nil
})
}
// execer 让 upsertTag 在普通连接和事务里都能跑。
type execer interface {
Exec(query string, args ...any) (sql.Result, error)
QueryRow(query string, args ...any) *sql.Row
}
func (s *Store) upsertTag(name string) (int64, error) {
return s.upsertTagOn(s.db, name)
}
func (s *Store) upsertTagOn(x execer, name string) (int64, error) {
slug := Slugify(name)
if slug == "" {
slug = "tag"
}
var id int64
err := x.QueryRow(s.db.Q(`SELECT id FROM tags WHERE slug = ?`), slug).Scan(&id)
if err == nil {
return id, nil
}
if err != sql.ErrNoRows {
return 0, err
}
if s.db.Dialect == db.Postgres {
err = x.QueryRow(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?) RETURNING id`), name, slug).Scan(&id)
return id, err
}
res, err := x.Exec(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?)`), name, slug)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) ListTags() ([]model.Tag, error) {
q := s.db.Q(`SELECT t.id, t.name, t.slug, t.color, COUNT(pt.post_id) AS c
FROM tags t LEFT JOIN post_tags pt ON pt.tag_id = t.id
GROUP BY t.id, t.name, t.slug, t.color ORDER BY c DESC, t.name`)
rows, err := s.db.Query(q)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.Tag{}
for rows.Next() {
var t model.Tag
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Color, &t.Count); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (s *Store) CreateTag(name string) (model.Tag, error) {
name = strings.TrimSpace(name)
if name == "" {
return model.Tag{}, errors.New("tag name required")
}
id, err := s.upsertTag(name)
if err != nil {
return model.Tag{}, err
}
return model.Tag{ID: id, Name: name, Slug: Slugify(name)}, nil
}
// CreateTagFull is used by the admin API when color is supplied.
func (s *Store) CreateTagFull(name, color string) (model.Tag, error) {
name = strings.TrimSpace(name)
if name == "" {
return model.Tag{}, errors.New("tag name required")
}
slug := Slugify(name)
if slug == "" {
return model.Tag{}, errors.New("tag slug required")
}
var id int64
err := s.db.QueryRow(s.db.Q(`SELECT id FROM tags WHERE slug = ?`), slug).Scan(&id)
if err != nil && err != sql.ErrNoRows {
return model.Tag{}, err
}
if err == nil {
// already exists — update color and return
if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET color=? WHERE id=?`), color, id); err != nil {
return model.Tag{}, err
}
return s.getTag(id)
}
color = strings.TrimSpace(color)
if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(s.db.Q(`INSERT INTO tags(name,slug,color) VALUES (?,?,?) RETURNING id`),
name, slug, color).Scan(&id)
if err != nil {
return model.Tag{}, err
}
} else {
res, err := s.db.Exec(s.db.Q(`INSERT INTO tags(name,slug,color) VALUES (?,?,?)`), name, slug, color)
if err != nil {
return model.Tag{}, err
}
id, err = res.LastInsertId()
if err != nil {
return model.Tag{}, err
}
}
return s.getTag(id)
}
func (s *Store) getTag(id int64) (model.Tag, error) {
var t model.Tag
err := s.db.QueryRow(s.db.Q(`SELECT t.id, t.name, t.slug, t.color, COUNT(pt.post_id)
FROM tags t LEFT JOIN post_tags pt ON pt.tag_id = t.id
WHERE t.id = ? GROUP BY t.id`), id).Scan(&t.ID, &t.Name, &t.Slug, &t.Color, &t.Count)
if err == sql.ErrNoRows {
return t, ErrNotFound
}
return t, err
}
func (s *Store) RenameTag(id int64, name string) (model.Tag, error) {
name = strings.TrimSpace(name)
slug := Slugify(name)
if name == "" {
return model.Tag{}, errors.New("tag name required")
}
if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET name=?, slug=? WHERE id=?`), name, slug, id); err != nil {
return model.Tag{}, err
}
return s.getTag(id)
}
// UpdateTag is the combined rename + recolor used by the admin UI.
func (s *Store) UpdateTag(id int64, name, color string) (model.Tag, error) {
name = strings.TrimSpace(name)
slug := Slugify(name)
if name == "" {
return model.Tag{}, errors.New("tag name required")
}
color = strings.TrimSpace(color)
if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET name=?, slug=?, color=? WHERE id=?`), name, slug, color, id); err != nil {
return model.Tag{}, err
}
return s.getTag(id)
}
// MergeTags moves every post from fromID to toID, then deletes fromID. It is a
// no-op if the ids are equal or either doesn't exist.
func (s *Store) MergeTags(fromID, toID int64) (model.Tag, error) {
if fromID == toID {
return model.Tag{}, errors.New("source and target are the same tag")
}
if _, err := s.getTag(fromID); err != nil {
return model.Tag{}, err
}
if _, err := s.getTag(toID); err != nil {
return model.Tag{}, err
}
// De-duplicate before the move so we don't end up with two rows pointing
// at the same post.
if err := s.tx(func(tx *sql.Tx) error {
if _, err := tx.Exec(s.db.Q(`DELETE FROM post_tags
WHERE post_id IN (SELECT post_id FROM post_tags WHERE tag_id = ?)
AND tag_id = ?`), toID, toID); err != nil {
return err
}
if _, err := tx.Exec(s.db.Q(`UPDATE post_tags SET tag_id=? WHERE tag_id=?`), toID, fromID); err != nil {
return err
}
_, err := tx.Exec(s.db.Q(`DELETE FROM tags WHERE id=?`), fromID)
return err
}); err != nil {
return model.Tag{}, err
}
return s.getTag(toID)
}
func (s *Store) DeleteTag(id int64) error {
return s.tx(func(tx *sql.Tx) error {
if _, err := tx.Exec(s.db.Q(`DELETE FROM post_tags WHERE tag_id = ?`), id); err != nil {
return err
}
_, err := tx.Exec(s.db.Q(`DELETE FROM tags WHERE id = ?`), id)
return err
})
}
// ---------- archive ----------
func (s *Store) Archive() ([]model.ArchiveYear, error) {
var items []model.Post
if err := s.scanPostsInto(s.db.Q(`SELECT `+listCols+` FROM posts
WHERE status = ? ORDER BY published_at DESC`), []any{model.StatusPublished}, &items); err != nil {
return nil, err
}
years := []model.ArchiveYear{}
yearIdx := map[string]int{}
monthIdx := map[string]int{}
for _, p := range items {
y, m := splitDate(p.PublishedAt)
if y == "" {
continue
}
yi, ok := yearIdx[y]
if !ok {
years = append(years, model.ArchiveYear{Year: y, Months: []model.ArchiveMonth{}})
yi = len(years) - 1
yearIdx[y] = yi
}
key := y + "-" + m
mi, ok := monthIdx[key]
if !ok {
years[yi].Months = append(years[yi].Months, model.ArchiveMonth{Month: m, Posts: []model.Post{}})
mi = len(years[yi].Months) - 1
monthIdx[key] = mi
}
years[yi].Count++
years[yi].Months[mi].Posts = append(years[yi].Months[mi].Posts, p)
}
return years, nil
}
func splitDate(rfc3339 string) (string, string) {
if len(rfc3339) < 7 {
return "", ""
}
return rfc3339[0:4], rfc3339[5:7]
}
// ---------- dashboard ----------
// Dashboard produces the snapshot rendered on the admin home page.
func (s *Store) Dashboard() (model.Dashboard, error) {
d := model.Dashboard{
RecentPosts: []model.Post{},
RecentDrafts: []model.Post{},
TopTags: []model.Tag{},
PublishedByMonth: []model.MonthBucket{},
}
// status / kind counts --------------------------------------------------
rows, err := s.db.Query(s.db.Q(`SELECT status, kind, COUNT(*) FROM posts GROUP BY status, kind`))
if err != nil {
return d, err
}
for rows.Next() {
var status, kind string
var n int
if err := rows.Scan(&status, &kind, &n); err != nil {
rows.Close()
return d, err
}
d.TotalPosts += n
switch status {
case model.StatusPublished:
d.PublishedPosts += n
case model.StatusDraft:
d.DraftPosts += n
}
switch kind {
case model.KindShort:
d.ShortPosts += n
case model.KindLong:
d.LongPosts += n
}
}
rows.Close()
// total tag count -------------------------------------------------------
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM tags`)).Scan(&d.TotalTags); err != nil {
return d, err
}
// total word count: sum of content_md length ----------------------------
if err := s.db.QueryRow(s.db.Q(`SELECT COALESCE(SUM(LENGTH(content_md)),0) FROM posts`)).Scan(&d.TotalWords); err != nil {
return d, err
}
// recent published posts (5) --------------------------------------------
if err := s.scanPostsInto(s.db.Q(`SELECT `+listCols+` FROM posts
WHERE status=? ORDER BY published_at DESC LIMIT 5`), []any{model.StatusPublished}, &d.RecentPosts); err != nil {
return d, err
}
// recent drafts (5) -----------------------------------------------------
if err := s.scanPostsInto(s.db.Q(`SELECT `+listCols+` FROM posts
WHERE status=? ORDER BY updated_at DESC LIMIT 5`), []any{model.StatusDraft}, &d.RecentDrafts); err != nil {
return d, err
}
// top tags (10) ---------------------------------------------------------
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM tags`)).Scan(&d.TotalTags); err != nil {
return d, err
}
tagRows, err := s.db.Query(s.db.Q(`SELECT t.id, t.name, t.slug, t.color, COUNT(pt.post_id) AS c
FROM tags t LEFT JOIN post_tags pt ON pt.tag_id = t.id
GROUP BY t.id, t.name, t.slug, t.color ORDER BY c DESC, t.name LIMIT 10`))
if err != nil {
return d, err
}
for tagRows.Next() {
var t model.Tag
if err := tagRows.Scan(&t.ID, &t.Name, &t.Slug, &t.Color, &t.Count); err != nil {
tagRows.Close()
return d, err
}
d.TopTags = append(d.TopTags, t)
}
tagRows.Close()
// published_by_month — last 12 months -----------------------------------
monthRows, err := s.db.Query(s.db.Q(`SELECT substr(published_at,1,7) AS m, COUNT(*) AS c
FROM posts WHERE status=? AND length(published_at) >= 7
GROUP BY m ORDER BY m DESC LIMIT 12`), model.StatusPublished)
if err != nil {
return d, err
}
defer monthRows.Close()
for monthRows.Next() {
var b model.MonthBucket
if err := monthRows.Scan(&b.Month, &b.Count); err != nil {
return d, err
}
d.PublishedByMonth = append(d.PublishedByMonth, b)
}
// reverse so we can render left-to-right on the chart
for i, j := 0, len(d.PublishedByMonth)-1; i < j; i, j = i+1, j-1 {
d.PublishedByMonth[i], d.PublishedByMonth[j] = d.PublishedByMonth[j], d.PublishedByMonth[i]
}
return d, nil
}
// scanPostsInto is a small helper that runs a query and scans rows into the
// given slice, attaching tags at the end.
func (s *Store) scanPostsInto(query string, args []any, dst *[]model.Post) error {
rows, err := s.db.Query(query, args...)
if err != nil {
return err
}
defer rows.Close()
out := []model.Post{}
for rows.Next() {
p, err := scanPost(rows)
if err != nil {
return err
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
return err
}
if err := s.attachTags(out); err != nil {
return err
}
*dst = out
return nil
}