安全加固 + 结构清理:修注入/串写/竞态,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:
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/httpx"
|
||||
@@ -19,6 +20,25 @@ type API struct {
|
||||
Store *store.Store
|
||||
Cfg *config.Config
|
||||
Sessions *Sessions
|
||||
|
||||
loginOnce sync.Once
|
||||
logins *loginLimiter
|
||||
}
|
||||
|
||||
func NewAPI(st *store.Store, cfg *config.Config, sessions *Sessions) *API {
|
||||
a := &API{Store: st, Cfg: cfg, Sessions: sessions}
|
||||
a.limiter()
|
||||
return a
|
||||
}
|
||||
|
||||
// limiter 惰性初始化,兼容测试里的 &API{...} 零值构造。
|
||||
func (a *API) limiter() *loginLimiter {
|
||||
a.loginOnce.Do(func() {
|
||||
if a.logins == nil {
|
||||
a.logins = newLoginLimiter()
|
||||
}
|
||||
})
|
||||
return a.logins
|
||||
}
|
||||
|
||||
const cookieName = "one_session"
|
||||
@@ -81,6 +101,11 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "POST required")
|
||||
return
|
||||
}
|
||||
key := sourceKey(r)
|
||||
if a.limiter().blocked(key) {
|
||||
httpx.Error(w, http.StatusTooManyRequests, "失败次数过多,请 10 分钟后再试")
|
||||
return
|
||||
}
|
||||
var in loginRequest
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
@@ -89,15 +114,18 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
userOK := subtle.ConstantTimeCompare([]byte(in.Username), []byte(a.Cfg.AdminUser)) == 1
|
||||
passOK := subtle.ConstantTimeCompare([]byte(in.Password), []byte(a.Cfg.AdminPass)) == 1
|
||||
if !userOK || !passOK {
|
||||
a.limiter().fail(key)
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
a.limiter().reset(key)
|
||||
token, exp := a.Sessions.Issue(a.Cfg.AdminUser)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: isTLS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: exp,
|
||||
MaxAge: a.Sessions.TTL(),
|
||||
@@ -113,6 +141,7 @@ func (a *API) logout(w http.ResponseWriter, r *http.Request) {
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: isTLS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/db"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
func newTestAPI(t *testing.T) (*API, http.Handler) {
|
||||
t.Helper()
|
||||
d, err := db.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
st, err := store.New(d)
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
cfg := &config.Config{AdminUser: "admin", AdminPass: "s3cret"}
|
||||
a := NewAPI(st, cfg, NewSessions("test-secret", time.Hour))
|
||||
return a, a.Routes()
|
||||
}
|
||||
|
||||
func login(t *testing.T, h http.Handler, user, pass string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(loginRequest{Username: user, Password: pass})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/login", strings.NewReader(string(body)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestGuardRejectsAnonymous(t *testing.T) {
|
||||
_, h := newTestAPI(t)
|
||||
for _, path := range []string{"/api/admin/posts", "/api/admin/settings", "/api/admin/dashboard"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s: got %d, want 401", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginIssuesUsableSession(t *testing.T) {
|
||||
_, h := newTestAPI(t)
|
||||
rec := login(t, h, "admin", "wrong")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong password: got %d, want 401", rec.Code)
|
||||
}
|
||||
rec = login(t, h, "admin", "s3cret")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login: got %d, want 200", rec.Code)
|
||||
}
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil || out.Token == "" {
|
||||
t.Fatalf("no token in response: %v", err)
|
||||
}
|
||||
// Bearer 通道
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/posts", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+out.Token)
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req)
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("bearer posts: got %d, want 200", rec2.Code)
|
||||
}
|
||||
// cookie 通道
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/admin/posts", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "one_session", Value: out.Token})
|
||||
rec3 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec3, req)
|
||||
if rec3.Code != http.StatusOK {
|
||||
t.Fatalf("cookie posts: got %d, want 200", rec3.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitedAfterRepeatedFailures(t *testing.T) {
|
||||
_, h := newTestAPI(t)
|
||||
var rec *httptest.ResponseRecorder
|
||||
for i := 0; i < maxLoginFails+1; i++ {
|
||||
rec = login(t, h, "admin", "bad")
|
||||
}
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("after %d failures: got %d, want 429", maxLoginFails+1, rec.Code)
|
||||
}
|
||||
// 限速期间即使口令正确也被拒
|
||||
rec = login(t, h, "admin", "s3cret")
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("locked out login: got %d, want 429", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureCookieBehindTLSProxy(t *testing.T) {
|
||||
_, h := newTestAPI(t)
|
||||
body, _ := json.Marshal(loginRequest{Username: "admin", Password: "s3cret"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/login", strings.NewReader(string(body)))
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login: %d", rec.Code)
|
||||
}
|
||||
cookie := rec.Header().Get("Set-Cookie")
|
||||
if !strings.Contains(cookie, "Secure") {
|
||||
t.Fatalf("expected Secure flag over https proxy, got: %s", cookie)
|
||||
}
|
||||
}
|
||||
|
||||
// ?order= 直接来自查询串并拼进 SQL,注入串必须被白名单挡掉且不影响数据。
|
||||
func TestOrderParamInjectionIsNeutralized(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
if _, err := a.Store.Create(model.PostInput{Title: "one", ContentMd: "a", Status: model.StatusPublished}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := login(t, h, "admin", "s3cret")
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.NewDecoder(token.Body).Decode(&out)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/posts?order="+
|
||||
strings.ReplaceAll("published_at DESC; DROP TABLE posts; --", " ", "%20"), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+out.Token)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("injected order: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 表若被 DROP,这条查询会报错
|
||||
if _, err := a.Store.List(store.ListOptions{Status: "any"}); err != nil {
|
||||
t.Fatalf("posts table damaged: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// loginLimiter 限制每个来源 IP 的登录失败次数(滑动窗口),
|
||||
// 防止默认/弱口令被在线暴力破解。成功登录后计数清零。
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
maxLoginFails = 10
|
||||
loginWindow = 10 * time.Minute
|
||||
)
|
||||
|
||||
func newLoginLimiter() *loginLimiter {
|
||||
return &loginLimiter{hits: make(map[string][]time.Time)}
|
||||
}
|
||||
|
||||
func (l *loginLimiter) blocked(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.recent(key, time.Now())) >= maxLoginFails
|
||||
}
|
||||
|
||||
func (l *loginLimiter) fail(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
l.hits[key] = append(l.recent(key, now), now)
|
||||
}
|
||||
|
||||
func (l *loginLimiter) reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.hits, key)
|
||||
}
|
||||
|
||||
// recent 返回窗口内的失败时间;调用方必须持有 l.mu。
|
||||
func (l *loginLimiter) recent(key string, now time.Time) []time.Time {
|
||||
hs := l.hits[key]
|
||||
cut := now.Add(-loginWindow)
|
||||
i := 0
|
||||
for ; i < len(hs); i++ {
|
||||
if hs[i].After(cut) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if i > 0 {
|
||||
hs = hs[i:]
|
||||
l.hits[key] = hs
|
||||
}
|
||||
if len(hs) == 0 {
|
||||
delete(l.hits, key)
|
||||
}
|
||||
return hs
|
||||
}
|
||||
|
||||
func sourceKey(r *http.Request) string {
|
||||
// 只信连接层地址;X-Forwarded-For 可被伪造,不作为限速键。
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// isTLS 判断最终用户看到的是不是 HTTPS(含反代 X-Forwarded-Proto)。
|
||||
func isTLS(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
}
|
||||
@@ -24,6 +24,10 @@ type API struct {
|
||||
func (a *API) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.Store.Ping(r.Context()); err != nil {
|
||||
httpx.Error(w, http.StatusServiceUnavailable, "database unreachable")
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true, "driver": a.Cfg.Driver})
|
||||
})
|
||||
mux.HandleFunc("/api/site", a.site)
|
||||
|
||||
@@ -13,20 +13,22 @@ const (
|
||||
)
|
||||
|
||||
type Post struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Summary string `json:"summary"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
ContentMd string `json:"content_md,omitempty"`
|
||||
ContentHTML string `json:"content_html"`
|
||||
Status string `json:"status"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ReadingMinutes int `json:"reading_minutes"`
|
||||
Tags []string `json:"tags"`
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Summary string `json:"summary"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
ContentMd string `json:"content_md,omitempty"`
|
||||
ContentHTML string `json:"content_html"`
|
||||
Status string `json:"status"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ReadingMinutes int `json:"reading_minutes"`
|
||||
// ContentLen 是正文字符数:列表接口不返回全文,但后台列表要显示字数。
|
||||
ContentLen int64 `json:"content_len"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type PostInput struct {
|
||||
@@ -70,16 +72,16 @@ type Page struct {
|
||||
|
||||
// Dashboard is the snapshot rendered on /admin (homepage).
|
||||
type Dashboard struct {
|
||||
TotalPosts int `json:"total_posts"`
|
||||
PublishedPosts int `json:"published_posts"`
|
||||
DraftPosts int `json:"draft_posts"`
|
||||
ShortPosts int `json:"short_posts"`
|
||||
LongPosts int `json:"long_posts"`
|
||||
TotalTags int `json:"total_tags"`
|
||||
TotalWords int `json:"total_words"`
|
||||
RecentPosts []Post `json:"recent_posts"`
|
||||
RecentDrafts []Post `json:"recent_drafts"`
|
||||
TopTags []Tag `json:"top_tags"`
|
||||
TotalPosts int `json:"total_posts"`
|
||||
PublishedPosts int `json:"published_posts"`
|
||||
DraftPosts int `json:"draft_posts"`
|
||||
ShortPosts int `json:"short_posts"`
|
||||
LongPosts int `json:"long_posts"`
|
||||
TotalTags int `json:"total_tags"`
|
||||
TotalWords int `json:"total_words"`
|
||||
RecentPosts []Post `json:"recent_posts"`
|
||||
RecentDrafts []Post `json:"recent_drafts"`
|
||||
TopTags []Tag `json:"top_tags"`
|
||||
PublishedByMonth []MonthBucket `json:"published_by_month"`
|
||||
}
|
||||
|
||||
|
||||
+118
-59
@@ -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
|
||||
}
|
||||
|
||||
@@ -43,11 +43,11 @@ func TestPostInputDefaults(t *testing.T) {
|
||||
|
||||
func TestNormalizeStatus(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": model.StatusDraft,
|
||||
"draft": model.StatusDraft,
|
||||
"published": model.StatusPublished,
|
||||
"": model.StatusDraft,
|
||||
"draft": model.StatusDraft,
|
||||
"published": model.StatusPublished,
|
||||
" Published ": model.StatusPublished,
|
||||
"pending": model.StatusDraft, // unknown → draft
|
||||
"pending": model.StatusDraft, // unknown → draft
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := NormalizeStatus(in); got != want {
|
||||
@@ -150,9 +150,9 @@ func TestStoreSQLite(t *testing.T) {
|
||||
// the value is preserved across updates.
|
||||
upd, err := s.Update(p1.ID, model.PostInput{
|
||||
Kind: model.KindLong, Title: "第一篇",
|
||||
CoverURL: p1.CoverURL,
|
||||
CoverURL: p1.CoverURL,
|
||||
ContentMd: "# hi\n这是新版本。",
|
||||
Tags: []string{"Go"},
|
||||
Tags: []string{"Go"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
@@ -264,3 +264,30 @@ func openTestStore(t *testing.T) *Store {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSanitizeOrder(t *testing.T) {
|
||||
if got := sanitizeOrder("published_at ASC"); got != "published_at asc" {
|
||||
t.Errorf("whitelisted order rejected: %q", got)
|
||||
}
|
||||
for _, bad := range []string{
|
||||
"published_at DESC; DROP TABLE posts; --",
|
||||
"(SELECT 1) DESC",
|
||||
"1 DESC",
|
||||
"published_at DESC, (SELECT COUNT(*) FROM sqlite_master) ASC",
|
||||
} {
|
||||
if got := sanitizeOrder(bad); got != "published_at desc" {
|
||||
t.Errorf("sanitizeOrder(%q) = %q, want fallback", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWithMaliciousOrderByFailsSafe(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
page, err := s.List(ListOptions{Status: "any", OrderBy: "published_at DESC; DROP TABLE posts; --"})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if page.Total != 0 {
|
||||
t.Errorf("unexpected total %d", page.Total)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user