安全加固 + 结构清理:修注入/串写/竞态,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 语义
This commit is contained in:
Sakurasan
2026-09-21 23:59:09 +08:00
parent c762f06cd7
commit dd2994189a
25 changed files with 539 additions and 1411 deletions
+118 -59
View File
@@ -3,6 +3,7 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
@@ -33,6 +34,9 @@ func New(d *db.DB) (*Store, error) {
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 {
@@ -244,13 +248,40 @@ type ListOptions struct {
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`
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.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
&p.ReadingMinutes, &p.ContentLen)
p.Tags = []string{}
return p, err
}
@@ -290,17 +321,14 @@ func (s *Store) List(o ListOptions) (model.Page, error) {
if len(where) > 0 {
w = "WHERE " + strings.Join(where, " AND ")
}
order := "published_at DESC"
if o.OrderBy != "" {
order = o.OrderBy
}
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 ?`, postCols, w, order))
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
@@ -383,7 +411,8 @@ func (s *Store) GetBySlug(slug string) (model.Post, error) {
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.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
&p.ReadingMinutes, &p.ContentLen)
if err == sql.ErrNoRows {
return ErrNotFound
}
@@ -583,51 +612,77 @@ func NormalizeStatus(s string) string {
}
func (s *Store) Delete(id int64) error {
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), id); err != nil {
return err
}
res, err := s.db.Exec(s.db.Q(`DELETE FROM posts WHERE id = ?`), id)
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
}
n, err := res.RowsAffected()
if err == nil && n == 0 {
return ErrNotFound
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
return nil
return tx.Commit()
}
// ---------- tags ----------
func (s *Store) setTags(postID int64, names []string) error {
if _, err := s.db.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.upsertTag(name)
if err != nil {
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
}
if _, err := s.db.Exec(s.db.Q(`INSERT INTO post_tags(post_id, tag_id) VALUES (?,?)`), postID, tagID); 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
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 := s.db.QueryRow(s.db.Q(`SELECT id FROM tags WHERE slug = ?`), slug).Scan(&id)
err := x.QueryRow(s.db.Q(`SELECT id FROM tags WHERE slug = ?`), slug).Scan(&id)
if err == nil {
return id, nil
}
@@ -635,10 +690,10 @@ func (s *Store) upsertTag(name string) (int64, error) {
return 0, err
}
if s.db.Dialect == db.Postgres {
err = s.db.QueryRow(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?) RETURNING id`), name, slug).Scan(&id)
err = x.QueryRow(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?) RETURNING id`), name, slug).Scan(&id)
return id, err
}
res, err := s.db.Exec(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?)`), name, slug)
res, err := x.Exec(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?)`), name, slug)
if err != nil {
return 0, err
}
@@ -770,41 +825,45 @@ func (s *Store) MergeTags(fromID, toID int64) (model.Tag, error) {
}
// De-duplicate before the move so we don't end up with two rows pointing
// at the same post.
if _, err := s.db.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 model.Tag{}, err
}
if _, err := s.db.Exec(s.db.Q(`UPDATE post_tags SET tag_id=? WHERE tag_id=?`), toID, fromID); err != nil {
return model.Tag{}, err
}
if _, err := s.db.Exec(s.db.Q(`DELETE FROM tags WHERE id=?`), fromID); err != nil {
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 {
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE tag_id = ?`), id); err != nil {
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
}
if _, err := s.db.Exec(s.db.Q(`DELETE FROM tags WHERE id = ?`), id); err != nil {
return err
}
return nil
})
}
// ---------- archive ----------
func (s *Store) Archive() ([]model.ArchiveYear, error) {
page, err := s.List(ListOptions{Status: model.StatusPublished, Page: 1, Size: 500})
if err != nil {
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 page.Items {
for _, p := range items {
y, m := splitDate(p.PublishedAt)
if y == "" {
continue
@@ -840,10 +899,10 @@ func splitDate(rfc3339 string) (string, string) {
// 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{},
RecentPosts: []model.Post{},
RecentDrafts: []model.Post{},
TopTags: []model.Tag{},
PublishedByMonth: []model.MonthBucket{},
}
// status / kind counts --------------------------------------------------
@@ -885,13 +944,13 @@ func (s *Store) Dashboard() (model.Dashboard, error) {
}
// recent published posts (5) --------------------------------------------
if err := s.scanPostsInto(s.db.Q(`SELECT `+postCols+` FROM posts
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 `+postCols+` FROM posts
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
}