diff --git a/backend/internal/admin/api.go b/backend/internal/admin/api.go
index 1d99990..1df9b51 100644
--- a/backend/internal/admin/api.go
+++ b/backend/internal/admin/api.go
@@ -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,
})
diff --git a/backend/internal/admin/api_test.go b/backend/internal/admin/api_test.go
new file mode 100644
index 0000000..9a98d0a
--- /dev/null
+++ b/backend/internal/admin/api_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/admin/limiter.go b/backend/internal/admin/limiter.go
new file mode 100644
index 0000000..24e90c9
--- /dev/null
+++ b/backend/internal/admin/limiter.go
@@ -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"
+}
diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go
index fac733f..4fb5cbf 100644
--- a/backend/internal/api/api.go
+++ b/backend/internal/api/api.go
@@ -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)
diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go
index e5309aa..ce9f139 100644
--- a/backend/internal/model/model.go
+++ b/backend/internal/model/model.go
@@ -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"`
}
diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go
index b852338..8687d93 100644
--- a/backend/internal/store/store.go
+++ b/backend/internal/store/store.go
@@ -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
}
diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go
index c317e0b..7bd9a13 100644
--- a/backend/internal/store/store_test.go
+++ b/backend/internal/store/store_test.go
@@ -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)
+ }
+}
diff --git a/backend/main.go b/backend/main.go
index cda6ab4..c8a262b 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -47,7 +47,7 @@ func main() {
}
public := &api.API{Store: st, Cfg: cfg}
- adminAPI := &admin.API{Store: st, Cfg: cfg, Sessions: admin.NewSessions(cfg.SessionSec, 7*24*time.Hour)}
+ adminAPI := admin.NewAPI(st, cfg, admin.NewSessions(cfg.SessionSec, 7*24*time.Hour))
root := http.NewServeMux()
root.Handle("/api/admin/", adminAPI.Routes())
diff --git a/frontend/package.json b/frontend/package.json
index a094bd8..60062d6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,15 +7,11 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "preview": "vite preview",
- "typecheck": "vue-tsc --noEmit"
+ "preview": "vite preview"
},
"dependencies": {
- "@egoist/tailwindcss-icons": "^1.8.0",
- "@iconify-json/mingcute": "^1.1.17",
"@milkdown/crepe": "^7.22.1",
"dompurify": "^3.1.6",
- "marked": "^12.0.2",
"vue": "^3.4.0",
"vue-router": "^4.4.0"
},
@@ -23,10 +19,8 @@
"@vitejs/plugin-vue": "^5.1.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.40",
- "tailwindcss": "^3.4.3",
"typescript": "^5.5.0",
- "vite": "^5.4.0",
- "vue-tsc": "^2.1.0"
+ "vite": "^5.4.0"
},
"pnpm": {
"onlyBuiltDependencies": [
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index a3b00d4..f2d5db6 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -8,21 +8,12 @@ importers:
.:
dependencies:
- '@egoist/tailwindcss-icons':
- specifier: ^1.8.0
- version: 1.9.2(tailwindcss@3.4.19)
- '@iconify-json/mingcute':
- specifier: ^1.1.17
- version: 1.2.7
'@milkdown/crepe':
specifier: ^7.22.1
version: 7.22.1(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.4)(typescript@5.9.3)
dompurify:
specifier: ^3.1.6
version: 3.4.12
- marked:
- specifier: ^12.0.2
- version: 12.0.2
vue:
specifier: ^3.4.0
version: 3.5.40(typescript@5.9.3)
@@ -39,28 +30,15 @@ importers:
postcss:
specifier: ^8.4.40
version: 8.5.25
- tailwindcss:
- specifier: ^3.4.3
- version: 3.4.19
typescript:
specifier: ^5.5.0
version: 5.9.3
vite:
specifier: ^5.4.0
version: 5.4.21
- vue-tsc:
- specifier: ^2.1.0
- version: 2.2.12(typescript@5.9.3)
packages:
- '@alloc/quick-lru@5.2.0':
- resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
- engines: {node: '>=10'}
-
- '@antfu/install-pkg@1.1.0':
- resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
-
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
@@ -171,11 +149,6 @@ packages:
'@codemirror/view@6.43.12':
resolution: {integrity: sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==}
- '@egoist/tailwindcss-icons@1.9.2':
- resolution: {integrity: sha512-I6XsSykmhu2cASg5Hp/ICLsJ/K/1aXPaSKjgbWaNp2xYnb4We/arWMmkhhV+9CglOFCUbqx0A3mM2kWV32ZIhw==}
- peerDependencies:
- tailwindcss: '*'
-
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
@@ -323,28 +296,9 @@ packages:
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
- '@iconify-json/mingcute@1.2.7':
- resolution: {integrity: sha512-bp4z6F53KAQp99aSL7qPCHL7HWUNGMNKIhfo/dx9pwVHiad/WusUbTOFXEZfpA44syJPzSOxa3O6Pwiq7qZz1g==}
-
- '@iconify/types@2.0.0':
- resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
-
- '@iconify/utils@3.1.4':
- resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==}
-
- '@jridgewell/gen-mapping@0.3.13':
- resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
-
- '@jridgewell/resolve-uri@3.1.2':
- resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
- engines: {node: '>=6.0.0'}
-
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
- '@jridgewell/trace-mapping@0.3.31':
- resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
-
'@lezer/common@1.5.2':
resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
@@ -472,18 +426,6 @@ packages:
'@milkdown/utils@7.22.1':
resolution: {integrity: sha512-ye0LGy/Ez8fjtcYyYj93wp4XfH00JYpCiy1IDHYB+9HVIpnbr3ed7PvY4s2000xmDWe0Vlx07TOyv96yvFrnjQ==}
- '@nodelib/fs.scandir@2.1.5':
- resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
- engines: {node: '>= 8'}
-
- '@nodelib/fs.stat@2.0.5':
- resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
- engines: {node: '>= 8'}
-
- '@nodelib/fs.walk@1.2.8':
- resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
- engines: {node: '>= 8'}
-
'@ocavue/utils@1.8.0':
resolution: {integrity: sha512-BD1ro+6Kllkmr/at+o32kvnLrVooUbk9gw3JAPaoAZXx+A5GhZ6Laa+UA38ifhG3vaamx+jcgEwX22BmTSMUVA==}
@@ -662,15 +604,6 @@ packages:
vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25
- '@volar/language-core@2.4.15':
- resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
-
- '@volar/source-map@2.4.15':
- resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==}
-
- '@volar/typescript@2.4.15':
- resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
-
'@vue/compiler-core@3.5.40':
resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==}
@@ -683,20 +616,9 @@ packages:
'@vue/compiler-ssr@3.5.40':
resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==}
- '@vue/compiler-vue2@2.7.16':
- resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
-
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
- '@vue/language-core@2.2.12':
- resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==}
- peerDependencies:
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
-
'@vue/reactivity@3.5.40':
resolution: {integrity: sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==}
@@ -712,19 +634,6 @@ packages:
'@vue/shared@3.5.40':
resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==}
- alien-signals@1.0.13:
- resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
-
- any-promise@1.3.0:
- resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
-
- anymatch@3.1.3:
- resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
- engines: {node: '>= 8'}
-
- arg@5.0.2:
- resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
-
autoprefixer@10.5.4:
resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==}
engines: {node: ^10 || ^12 || >=14}
@@ -735,34 +644,16 @@ packages:
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
- balanced-match@1.0.2:
- resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
-
baseline-browser-mapping@2.11.8:
resolution: {integrity: sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ==}
engines: {node: '>=6.0.0'}
hasBin: true
- binary-extensions@2.3.0:
- resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
- engines: {node: '>=8'}
-
- brace-expansion@2.1.4:
- resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
-
- braces@3.0.3:
- resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
- engines: {node: '>=8'}
-
browserslist@4.28.7:
resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
- camelcase-css@2.0.1:
- resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
- engines: {node: '>= 6'}
-
caniuse-lite@1.0.30001806:
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
@@ -772,10 +663,6 @@ packages:
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
- chokidar@3.6.0:
- resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
- engines: {node: '>= 8.10.0'}
-
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -787,10 +674,6 @@ packages:
resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==}
engines: {node: '>=22.12.0'}
- commander@4.1.1:
- resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
- engines: {node: '>= 6'}
-
commander@8.3.0:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
@@ -798,17 +681,9 @@ packages:
crelt@1.0.7:
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
- cssesc@3.0.0:
- resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
- engines: {node: '>=4'}
- hasBin: true
-
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
- de-indent@1.0.2:
- resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
-
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -828,12 +703,6 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
- didyoumean@1.2.2:
- resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
-
- dlv@1.1.3:
- resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
-
dompurify@3.4.12:
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
@@ -844,10 +713,6 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
- es-errors@1.3.0:
- resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
- engines: {node: '>= 0.4'}
-
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -867,26 +732,6 @@ packages:
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
- fast-glob@3.3.3:
- resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
- engines: {node: '>=8.6.0'}
-
- fastq@1.20.1:
- resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
-
- fdir@6.5.0:
- resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
- engines: {node: '>=12.0.0'}
- peerDependencies:
- picomatch: ^3 || ^4
- peerDependenciesMeta:
- picomatch:
- optional: true
-
- fill-range@7.1.1:
- resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
- engines: {node: '>=8'}
-
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
@@ -895,56 +740,10 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
- function-bind@1.1.2:
- resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
-
- glob-parent@5.1.2:
- resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
- engines: {node: '>= 6'}
-
- glob-parent@6.0.2:
- resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
- engines: {node: '>=10.13.0'}
-
- hasown@2.0.4:
- resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
- engines: {node: '>= 0.4'}
-
- he@1.2.0:
- resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
- hasBin: true
-
- import-meta-resolve@4.2.0:
- resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
-
- is-binary-path@2.1.0:
- resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
- engines: {node: '>=8'}
-
- is-core-module@2.16.2:
- resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
- engines: {node: '>= 0.4'}
-
- is-extglob@2.1.1:
- resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
- engines: {node: '>=0.10.0'}
-
- is-glob@4.0.3:
- resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
- engines: {node: '>=0.10.0'}
-
- is-number@7.0.0:
- resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
- engines: {node: '>=0.12.0'}
-
is-plain-obj@4.1.0:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
- jiti@1.21.7:
- resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
- hasBin: true
-
katex@0.16.47:
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
hasBin: true
@@ -953,13 +752,6 @@ packages:
resolution: {integrity: sha512-h+UCwkZ+4Jz8WQ7MLGfj7UVFrRCizGb912fwF4luGdYsC5paYG1vx+jy+KRcC/XkpjGva/P7nAWuxNnPzRvzHw==}
hasBin: true
- lilconfig@3.1.3:
- resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
- engines: {node: '>=14'}
-
- lines-and-columns@1.2.4:
- resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
-
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
@@ -972,11 +764,6 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
- marked@12.0.2:
- resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==}
- engines: {node: '>= 18'}
- hasBin: true
-
mdast-util-definitions@6.0.0:
resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==}
@@ -1016,10 +803,6 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
- merge2@1.4.1:
- resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
- engines: {node: '>= 8'}
-
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
@@ -1107,23 +890,9 @@ packages:
micromark@4.0.2:
resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
- micromatch@4.0.8:
- resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
- engines: {node: '>=8.6'}
-
- minimatch@9.0.9:
- resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
- engines: {node: '>=16 || 14 >=14.17'}
-
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- muggle-string@0.4.1:
- resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
-
- mz@2.7.0:
- resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
-
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -1138,89 +907,12 @@ packages:
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
engines: {node: '>=18'}
- normalize-path@3.0.0:
- resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
- engines: {node: '>=0.10.0'}
-
- object-assign@4.1.1:
- resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
- engines: {node: '>=0.10.0'}
-
- object-hash@3.0.0:
- resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
- engines: {node: '>= 6'}
-
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
- package-manager-detector@1.8.0:
- resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==}
-
- path-browserify@1.0.1:
- resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
-
- path-parse@1.0.7:
- resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
-
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
- picomatch@2.3.2:
- resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
- engines: {node: '>=8.6'}
-
- picomatch@4.0.5:
- resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
- engines: {node: '>=12'}
-
- pify@2.3.0:
- resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
- engines: {node: '>=0.10.0'}
-
- pirates@4.0.7:
- resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
- engines: {node: '>= 6'}
-
- postcss-import@15.1.0:
- resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
- engines: {node: '>=14.0.0'}
- peerDependencies:
- postcss: ^8.0.0
-
- postcss-js@4.1.0:
- resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
- engines: {node: ^12 || ^14 || >= 16}
- peerDependencies:
- postcss: ^8.4.21
-
- postcss-load-config@6.0.1:
- resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
- engines: {node: '>= 18'}
- peerDependencies:
- jiti: '>=1.21.0'
- postcss: '>=8.0.9'
- tsx: ^4.8.1
- yaml: ^2.4.2
- peerDependenciesMeta:
- jiti:
- optional: true
- postcss:
- optional: true
- tsx:
- optional: true
- yaml:
- optional: true
-
- postcss-nested@6.2.0:
- resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
- engines: {node: '>=12.0'}
- peerDependencies:
- postcss: ^8.2.14
-
- postcss-selector-parser@6.1.4:
- resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==}
- engines: {node: '>=4'}
-
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
@@ -1287,16 +979,6 @@ packages:
prosemirror-view:
optional: true
- queue-microtask@1.2.3:
- resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
-
- read-cache@1.0.0:
- resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
-
- readdirp@3.6.0:
- resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
- engines: {node: '>=8.10.0'}
-
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -1315,15 +997,6 @@ packages:
remark@15.0.1:
resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==}
- resolve@1.22.12:
- resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
- engines: {node: '>= 0.4'}
- hasBin: true
-
- reusify@1.1.0:
- resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
- engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
-
rollup@4.62.3:
resolution: {integrity: sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -1332,9 +1005,6 @@ packages:
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
- run-parallel@1.2.0:
- resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
-
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1342,45 +1012,9 @@ packages:
style-mod@4.1.4:
resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==}
- sucrase@3.35.1:
- resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
- engines: {node: '>=16 || 14 >=14.17'}
- hasBin: true
-
- supports-preserve-symlinks-flag@1.0.0:
- resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
- engines: {node: '>= 0.4'}
-
- tailwindcss@3.4.19:
- resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
- engines: {node: '>=14.0.0'}
- hasBin: true
-
- thenify-all@1.6.0:
- resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
- engines: {node: '>=0.8'}
-
- thenify@3.3.1:
- resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
-
- tinyexec@1.2.4:
- resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
- engines: {node: '>=18'}
-
- tinyglobby@0.2.17:
- resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
- engines: {node: '>=12.0.0'}
-
- to-regex-range@5.0.1:
- resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
- engines: {node: '>=8.0'}
-
trough@2.2.0:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
- ts-interface-checker@0.1.13:
- resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
-
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -1410,9 +1044,6 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
- util-deprecate@1.0.2:
- resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
-
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -1450,20 +1081,11 @@ packages:
terser:
optional: true
- vscode-uri@3.1.0:
- resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
-
vue-router@4.6.4:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies:
vue: ^3.5.0
- vue-tsc@2.2.12:
- resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==}
- hasBin: true
- peerDependencies:
- typescript: '>=5.0.0'
-
vue@3.5.40:
resolution: {integrity: sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==}
peerDependencies:
@@ -1480,13 +1102,6 @@ packages:
snapshots:
- '@alloc/quick-lru@5.2.0': {}
-
- '@antfu/install-pkg@1.1.0':
- dependencies:
- package-manager-detector: 1.8.0
- tinyexec: 1.2.4
-
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
@@ -1758,11 +1373,6 @@ snapshots:
style-mod: 4.1.4
w3c-keyname: 2.2.8
- '@egoist/tailwindcss-icons@1.9.2(tailwindcss@3.4.19)':
- dependencies:
- '@iconify/utils': 3.1.4
- tailwindcss: 3.4.19
-
'@esbuild/aix-ppc64@0.21.5':
optional: true
@@ -1843,32 +1453,8 @@ snapshots:
'@floating-ui/utils@0.2.12': {}
- '@iconify-json/mingcute@1.2.7':
- dependencies:
- '@iconify/types': 2.0.0
-
- '@iconify/types@2.0.0': {}
-
- '@iconify/utils@3.1.4':
- dependencies:
- '@antfu/install-pkg': 1.1.0
- '@iconify/types': 2.0.0
- import-meta-resolve: 4.2.0
-
- '@jridgewell/gen-mapping@0.3.13':
- dependencies:
- '@jridgewell/sourcemap-codec': 1.5.5
- '@jridgewell/trace-mapping': 0.3.31
-
- '@jridgewell/resolve-uri@3.1.2': {}
-
'@jridgewell/sourcemap-codec@1.5.5': {}
- '@jridgewell/trace-mapping@0.3.31':
- dependencies:
- '@jridgewell/resolve-uri': 3.1.2
- '@jridgewell/sourcemap-codec': 1.5.5
-
'@lezer/common@1.5.2': {}
'@lezer/cpp@1.1.6':
@@ -2247,18 +1833,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@nodelib/fs.scandir@2.1.5':
- dependencies:
- '@nodelib/fs.stat': 2.0.5
- run-parallel: 1.2.0
-
- '@nodelib/fs.stat@2.0.5': {}
-
- '@nodelib/fs.walk@1.2.8':
- dependencies:
- '@nodelib/fs.scandir': 2.1.5
- fastq: 1.20.1
-
'@ocavue/utils@1.8.0': {}
'@rollup/rollup-android-arm-eabi@4.62.3':
@@ -2370,18 +1944,6 @@ snapshots:
vite: 5.4.21
vue: 3.5.40(typescript@5.9.3)
- '@volar/language-core@2.4.15':
- dependencies:
- '@volar/source-map': 2.4.15
-
- '@volar/source-map@2.4.15': {}
-
- '@volar/typescript@2.4.15':
- dependencies:
- '@volar/language-core': 2.4.15
- path-browserify: 1.0.1
- vscode-uri: 3.1.0
-
'@vue/compiler-core@3.5.40':
dependencies:
'@babel/parser': 7.29.7
@@ -2412,26 +1974,8 @@ snapshots:
'@vue/compiler-dom': 3.5.40
'@vue/shared': 3.5.40
- '@vue/compiler-vue2@2.7.16':
- dependencies:
- de-indent: 1.0.2
- he: 1.2.0
-
'@vue/devtools-api@6.6.4': {}
- '@vue/language-core@2.2.12(typescript@5.9.3)':
- dependencies:
- '@volar/language-core': 2.4.15
- '@vue/compiler-dom': 3.5.40
- '@vue/compiler-vue2': 2.7.16
- '@vue/shared': 3.5.40
- alien-signals: 1.0.13
- minimatch: 9.0.9
- muggle-string: 0.4.1
- path-browserify: 1.0.1
- optionalDependencies:
- typescript: 5.9.3
-
'@vue/reactivity@3.5.40':
dependencies:
'@vue/shared': 3.5.40
@@ -2456,17 +2000,6 @@ snapshots:
'@vue/shared@3.5.40': {}
- alien-signals@1.0.13: {}
-
- any-promise@1.3.0: {}
-
- anymatch@3.1.3:
- dependencies:
- normalize-path: 3.0.0
- picomatch: 2.3.2
-
- arg@5.0.2: {}
-
autoprefixer@10.5.4(postcss@8.5.25):
dependencies:
browserslist: 4.28.7
@@ -2478,20 +2011,8 @@ snapshots:
bail@2.0.2: {}
- balanced-match@1.0.2: {}
-
baseline-browser-mapping@2.11.8: {}
- binary-extensions@2.3.0: {}
-
- brace-expansion@2.1.4:
- dependencies:
- balanced-match: 1.0.2
-
- braces@3.0.3:
- dependencies:
- fill-range: 7.1.1
-
browserslist@4.28.7:
dependencies:
baseline-browser-mapping: 2.11.8
@@ -2500,26 +2021,12 @@ snapshots:
node-releases: 2.0.51
update-browserslist-db: 1.2.3(browserslist@4.28.7)
- camelcase-css@2.0.1: {}
-
caniuse-lite@1.0.30001806: {}
ccount@2.0.1: {}
character-entities@2.0.2: {}
- chokidar@3.6.0:
- dependencies:
- anymatch: 3.1.3
- braces: 3.0.3
- glob-parent: 5.1.2
- is-binary-path: 2.1.0
- is-glob: 4.0.3
- normalize-path: 3.0.0
- readdirp: 3.6.0
- optionalDependencies:
- fsevents: 2.3.3
-
clsx@2.1.1: {}
codemirror@6.0.2:
@@ -2534,18 +2041,12 @@ snapshots:
commander@15.0.0: {}
- commander@4.1.1: {}
-
commander@8.3.0: {}
crelt@1.0.7: {}
- cssesc@3.0.0: {}
-
csstype@3.2.3: {}
- de-indent@1.0.2: {}
-
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -2560,10 +2061,6 @@ snapshots:
dependencies:
dequal: 2.0.3
- didyoumean@1.2.2: {}
-
- dlv@1.1.3: {}
-
dompurify@3.4.12:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -2572,8 +2069,6 @@ snapshots:
entities@7.0.1: {}
- es-errors@1.3.0: {}
-
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -2608,69 +2103,13 @@ snapshots:
extend@3.0.2: {}
- fast-glob@3.3.3:
- dependencies:
- '@nodelib/fs.stat': 2.0.5
- '@nodelib/fs.walk': 1.2.8
- glob-parent: 5.1.2
- merge2: 1.4.1
- micromatch: 4.0.8
-
- fastq@1.20.1:
- dependencies:
- reusify: 1.1.0
-
- fdir@6.5.0(picomatch@4.0.5):
- optionalDependencies:
- picomatch: 4.0.5
-
- fill-range@7.1.1:
- dependencies:
- to-regex-range: 5.0.1
-
fraction.js@5.3.4: {}
fsevents@2.3.3:
optional: true
- function-bind@1.1.2: {}
-
- glob-parent@5.1.2:
- dependencies:
- is-glob: 4.0.3
-
- glob-parent@6.0.2:
- dependencies:
- is-glob: 4.0.3
-
- hasown@2.0.4:
- dependencies:
- function-bind: 1.1.2
-
- he@1.2.0: {}
-
- import-meta-resolve@4.2.0: {}
-
- is-binary-path@2.1.0:
- dependencies:
- binary-extensions: 2.3.0
-
- is-core-module@2.16.2:
- dependencies:
- hasown: 2.0.4
-
- is-extglob@2.1.1: {}
-
- is-glob@4.0.3:
- dependencies:
- is-extglob: 2.1.1
-
- is-number@7.0.0: {}
-
is-plain-obj@4.1.0: {}
- jiti@1.21.7: {}
-
katex@0.16.47:
dependencies:
commander: 8.3.0
@@ -2679,10 +2118,6 @@ snapshots:
dependencies:
commander: 15.0.0
- lilconfig@3.1.3: {}
-
- lines-and-columns@1.2.4: {}
-
lodash-es@4.18.1: {}
longest-streak@3.1.0: {}
@@ -2693,8 +2128,6 @@ snapshots:
markdown-table@3.0.4: {}
- marked@12.0.2: {}
-
mdast-util-definitions@6.0.0:
dependencies:
'@types/mdast': 4.0.4
@@ -2815,8 +2248,6 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
- merge2@1.4.1: {}
-
micromark-core-commonmark@2.0.3:
dependencies:
decode-named-character-reference: 1.3.0
@@ -3018,84 +2449,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
- micromatch@4.0.8:
- dependencies:
- braces: 3.0.3
- picomatch: 2.3.2
-
- minimatch@9.0.9:
- dependencies:
- brace-expansion: 2.1.4
-
ms@2.1.3: {}
- muggle-string@0.4.1: {}
-
- mz@2.7.0:
- dependencies:
- any-promise: 1.3.0
- object-assign: 4.1.1
- thenify-all: 1.6.0
-
nanoid@3.3.16: {}
nanoid@6.0.1: {}
node-releases@2.0.51: {}
- normalize-path@3.0.0: {}
-
- object-assign@4.1.1: {}
-
- object-hash@3.0.0: {}
-
orderedmap@2.1.1: {}
- package-manager-detector@1.8.0: {}
-
- path-browserify@1.0.1: {}
-
- path-parse@1.0.7: {}
-
picocolors@1.1.1: {}
- picomatch@2.3.2: {}
-
- picomatch@4.0.5: {}
-
- pify@2.3.0: {}
-
- pirates@4.0.7: {}
-
- postcss-import@15.1.0(postcss@8.5.25):
- dependencies:
- postcss: 8.5.25
- postcss-value-parser: 4.2.0
- read-cache: 1.0.0
- resolve: 1.22.12
-
- postcss-js@4.1.0(postcss@8.5.25):
- dependencies:
- camelcase-css: 2.0.1
- postcss: 8.5.25
-
- postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.25):
- dependencies:
- lilconfig: 3.1.3
- optionalDependencies:
- jiti: 1.21.7
- postcss: 8.5.25
-
- postcss-nested@6.2.0(postcss@8.5.25):
- dependencies:
- postcss: 8.5.25
- postcss-selector-parser: 6.1.4
-
- postcss-selector-parser@6.1.4:
- dependencies:
- cssesc: 3.0.0
- util-deprecate: 1.0.2
-
postcss-value-parser@4.2.0: {}
postcss@8.5.25:
@@ -3196,16 +2561,6 @@ snapshots:
prosemirror-state: 1.4.4
prosemirror-view: 1.42.4
- queue-microtask@1.2.3: {}
-
- read-cache@1.0.0:
- dependencies:
- pify: 2.3.0
-
- readdirp@3.6.0:
- dependencies:
- picomatch: 2.3.2
-
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
@@ -3256,15 +2611,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- resolve@1.22.12:
- dependencies:
- es-errors: 1.3.0
- is-core-module: 2.16.2
- path-parse: 1.0.7
- supports-preserve-symlinks-flag: 1.0.0
-
- reusify@1.1.0: {}
-
rollup@4.62.3:
dependencies:
'@types/estree': 1.0.9
@@ -3298,77 +2644,12 @@ snapshots:
rope-sequence@1.3.4: {}
- run-parallel@1.2.0:
- dependencies:
- queue-microtask: 1.2.3
-
source-map-js@1.2.1: {}
style-mod@4.1.4: {}
- sucrase@3.35.1:
- dependencies:
- '@jridgewell/gen-mapping': 0.3.13
- commander: 4.1.1
- lines-and-columns: 1.2.4
- mz: 2.7.0
- pirates: 4.0.7
- tinyglobby: 0.2.17
- ts-interface-checker: 0.1.13
-
- supports-preserve-symlinks-flag@1.0.0: {}
-
- tailwindcss@3.4.19:
- dependencies:
- '@alloc/quick-lru': 5.2.0
- arg: 5.0.2
- chokidar: 3.6.0
- didyoumean: 1.2.2
- dlv: 1.1.3
- fast-glob: 3.3.3
- glob-parent: 6.0.2
- is-glob: 4.0.3
- jiti: 1.21.7
- lilconfig: 3.1.3
- micromatch: 4.0.8
- normalize-path: 3.0.0
- object-hash: 3.0.0
- picocolors: 1.1.1
- postcss: 8.5.25
- postcss-import: 15.1.0(postcss@8.5.25)
- postcss-js: 4.1.0(postcss@8.5.25)
- postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.25)
- postcss-nested: 6.2.0(postcss@8.5.25)
- postcss-selector-parser: 6.1.4
- resolve: 1.22.12
- sucrase: 3.35.1
- transitivePeerDependencies:
- - tsx
- - yaml
-
- thenify-all@1.6.0:
- dependencies:
- thenify: 3.3.1
-
- thenify@3.3.1:
- dependencies:
- any-promise: 1.3.0
-
- tinyexec@1.2.4: {}
-
- tinyglobby@0.2.17:
- dependencies:
- fdir: 6.5.0(picomatch@4.0.5)
- picomatch: 4.0.5
-
- to-regex-range@5.0.1:
- dependencies:
- is-number: 7.0.0
-
trough@2.2.0: {}
- ts-interface-checker@0.1.13: {}
-
typescript@5.9.3: {}
unified@11.0.5:
@@ -3411,8 +2692,6 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
- util-deprecate@1.0.2: {}
-
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -3431,19 +2710,11 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
- vscode-uri@3.1.0: {}
-
vue-router@4.6.4(vue@3.5.40(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.40(typescript@5.9.3)
- vue-tsc@2.2.12(typescript@5.9.3):
- dependencies:
- '@volar/typescript': 2.4.15
- '@vue/language-core': 2.2.12(typescript@5.9.3)
- typescript: 5.9.3
-
vue@3.5.40(typescript@5.9.3):
dependencies:
'@vue/compiler-dom': 3.5.40
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
index 2e7af2b..b6dc034 100644
--- a/frontend/postcss.config.js
+++ b/frontend/postcss.config.js
@@ -1,6 +1,5 @@
export default {
plugins: {
- tailwindcss: {},
autoprefixer: {},
},
}
diff --git a/frontend/src/admin/AdminLayout.vue b/frontend/src/admin/AdminLayout.vue
index cbdff79..84559b5 100644
--- a/frontend/src/admin/AdminLayout.vue
+++ b/frontend/src/admin/AdminLayout.vue
@@ -38,10 +38,12 @@ onMounted(async () => {
})
// 会话过期时(请求返回 401)统一跳回登录页
-window.addEventListener('one:unauthorized', () => {
+function onUnauthorized() {
session.clear()
if (router.currentRoute.value.path.startsWith('/admin')) router.replace('/admin/login')
-})
+}
+onMounted(() => window.addEventListener('one:unauthorized', onUnauthorized))
+onBeforeUnmount(() => window.removeEventListener('one:unauthorized', onUnauthorized))
async function logout() {
try {
@@ -149,7 +151,8 @@ watch(() => route.path, () => loadCounts())
{{ error }}
+{{ error }}