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())
- + +
diff --git a/frontend/src/admin/CommandPalette.vue b/frontend/src/admin/CommandPalette.vue index 572d628..7b062c9 100644 --- a/frontend/src/admin/CommandPalette.vue +++ b/frontend/src/admin/CommandPalette.vue @@ -87,6 +87,10 @@ function onKey(e) { e.preventDefault() const it = flat.value[idx.value] if (it) run(it.item) + } else if (e.key === 'Tab') { + // 面板内只有一个输入框,Tab 不外逃 + e.preventDefault() + inputEl.value?.focus() } } @@ -107,9 +111,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) v-model="query" placeholder="输入命令、文章标题…" aria-label="搜索命令和文章" + role="combobox" + aria-expanded="true" + aria-controls="cmd-listbox" + :aria-activedescendant="flat.length ? 'cmd-opt-' + idx : undefined" @input="idx = 0" /> -
+