From 6108aca34c2c3d307f50910e2b5b705e21fa3193 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:34:19 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=A9=E7=95=A5=E5=9B=BE=E6=87=92=E7=94=9F?= =?UTF-8?q?=E6=88=90=20+=20=E8=AF=84=E8=AE=BA=E7=99=BB=E5=BD=95=E9=99=90?= =?UTF-8?q?=E6=B5=81=EF=BC=9A=E6=97=B6=E9=97=B4=E7=BA=BF=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=8B=89=E5=8E=9F=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /uploads/thumb/{key}?w=:首访从存储端读一次原图,标准库盒均值缩放后 落盘 data/.thumbnail_cache(按内容哈希命名,天然失效);失败写 .failed 冷却 一小时,非图片/超大一律 302 回原图,前端无感。原图路由改用 ServeContent, 补上视频拖动进度条所需的 Range 支持 - 前端 thumbURL/thumbifyHtml 接入两套 UI 的时间线配图、长文封面与短文正文, 并补 loading=lazy;详情页与灯箱仍用原图。/api/site 改为 {settings, uploads_public_base},让前端识别哪些直链属于自家存储 - 后台登录的滑动窗口限速器抽成 internal/ratelimit 共享包(调用面不变), 新增:读者登录失败按 IP 20 次/10 分钟、评论写入按读者 5 条/分钟 (站主豁免,且只计成功写入) --- backend/internal/admin/api.go | 6 +- backend/internal/admin/limiter.go | 60 +----- backend/internal/api/api.go | 35 ++- backend/internal/api/api_test.go | 96 +++++++++ backend/internal/api/comments.go | 21 ++ backend/internal/api/providers.go | 17 ++ backend/internal/api/thumb.go | 141 ++++++++++++ backend/internal/ratelimit/ratelimit.go | 73 +++++++ backend/internal/thumbs/thumbs.go | 275 ++++++++++++++++++++++++ backend/internal/thumbs/thumbs_test.go | 94 ++++++++ backend/main.go | 7 +- frontend/src/components/PostCard.vue | 14 +- frontend/src/site.js | 8 +- frontend/src/ui/yohaku/YohakuFeed.vue | 7 +- frontend/src/ui/yohaku/YohakuHome.vue | 3 +- frontend/src/utils.js | 34 +++ frontend/vite.config.ts | 2 + 17 files changed, 822 insertions(+), 71 deletions(-) create mode 100644 backend/internal/api/api_test.go create mode 100644 backend/internal/api/thumb.go create mode 100644 backend/internal/ratelimit/ratelimit.go create mode 100644 backend/internal/thumbs/thumbs.go create mode 100644 backend/internal/thumbs/thumbs_test.go diff --git a/backend/internal/admin/api.go b/backend/internal/admin/api.go index 4332296..47d55fb 100644 --- a/backend/internal/admin/api.go +++ b/backend/internal/admin/api.go @@ -138,7 +138,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) { return } key := sourceKey(r) - if a.limiter().blocked(key) { + if a.limiter().Blocked(key) { httpx.Error(w, http.StatusTooManyRequests, "失败次数过多,请 10 分钟后再试") return } @@ -150,11 +150,11 @@ 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) + a.limiter().Add(key) httpx.Unauthorized(w) return } - a.limiter().reset(key) + a.limiter().Reset(key) token, exp := a.Sessions.Issue(a.Cfg.AdminUser) http.SetCookie(w, &http.Cookie{ Name: cookieName, diff --git a/backend/internal/admin/limiter.go b/backend/internal/admin/limiter.go index 24e90c9..329255c 100644 --- a/backend/internal/admin/limiter.go +++ b/backend/internal/admin/limiter.go @@ -1,18 +1,17 @@ package admin import ( - "net" "net/http" - "sync" "time" + + "oneblog/internal/ratelimit" ) // loginLimiter 限制每个来源 IP 的登录失败次数(滑动窗口), // 防止默认/弱口令被在线暴力破解。成功登录后计数清零。 -type loginLimiter struct { - mu sync.Mutex - hits map[string][]time.Time -} +// 实现挪到了 internal/ratelimit(读者登录/评论写入共用),这里保留 +// 原有调用面与常量。 +type loginLimiter = ratelimit.Window const ( maxLoginFails = 10 @@ -20,55 +19,10 @@ const ( ) func newLoginLimiter() *loginLimiter { - return &loginLimiter{hits: make(map[string][]time.Time)} + return ratelimit.New(maxLoginFails, loginWindow) } -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 -} +func sourceKey(r *http.Request) string { return ratelimit.SourceKey(r) } // isTLS 判断最终用户看到的是不是 HTTPS(含反代 X-Forwarded-Proto)。 func isTLS(r *http.Request) bool { diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 09adb88..3be969b 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -16,14 +16,19 @@ import ( "oneblog/internal/config" "oneblog/internal/httpx" "oneblog/internal/model" + "oneblog/internal/ratelimit" "oneblog/internal/storage" "oneblog/internal/store" + "oneblog/internal/thumbs" ) type API struct { Store *store.Store Cfg *config.Config Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享) + // Thumbs 是缩略图磁盘缓存(main.go 装配,DataDir/.thumbnail_cache)。 + // 为 nil 时 /uploads/thumb/ 路由直接回原图。 + Thumbs *thumbs.Store // 评论区读者会话与 GitHub OAuth(main.go 装配) ReaderSessions *auth.ReaderSessions GH auth.GitHub @@ -35,9 +40,26 @@ type API struct { // 其余登录方式(main.go 装配,未配置的自动不开放) GG auth.Google TG auth.Telegram + + // 限流(Routes 里惰性初始化):读者登录失败按 IP 计、评论写入按读者计。 + // 登录入口此前裸奔——OAuth 跳转本身难刷,但 state 校验失败、 + // Telegram 伪造签名这类恶意请求需要一个兜底。 + authFails *ratelimit.Window + commentNew *ratelimit.Window } +const ( + maxAuthFails = 20 // 窗口内允许的登录失败(含伪造回调) + authFailWindow = 10 * time.Minute + maxComments = 5 // 每个读者每窗口最多发几条 + commentWindow = time.Minute +) + func (a *API) Routes() http.Handler { + if a.authFails == nil { + a.authFails = ratelimit.New(maxAuthFails, authFailWindow) + a.commentNew = ratelimit.New(maxComments, commentWindow) + } mux := http.NewServeMux() mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { if err := a.Store.Ping(r.Context()); err != nil { @@ -81,7 +103,8 @@ func (a *API) site(w http.ResponseWriter, r *http.Request) { httpx.ServerError(w, err) return } - httpx.OK(w, st) + // uploads_public_base 告诉前端哪些图片直链是自己的存储(可转 /uploads/thumb/ 缩略图) + httpx.OK(w, map[string]any{"settings": st, "uploads_public_base": a.Cfg.UploadsPublicBase}) } func listOptions(r *http.Request, defSize int) store.ListOptions { @@ -341,11 +364,17 @@ func (a *API) uploads(w http.ResponseWriter, r *http.Request) { } defer rc.Close() + // ServeContent 自带 Range(视频/音频拖动进度条必需)、ETag 比对与 304。 + // key 含内容哈希:内容不变则 URL 不变,可永久缓存。 w.Header().Set("Content-Type", f.Mime) - w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) w.Header().Set("ETag", `"`+f.SHA256+`"`) - // key 含内容哈希:内容不变则 URL 不变,可永久缓存 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + if rs, ok := rc.(io.ReadSeeker); ok { + http.ServeContent(w, r, f.Name, time.Time{}, rs) + return + } + // 非本地存储拿不到 Seeker 时退回流式拷贝 + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) if match := r.Header.Get("If-None-Match"); match != "" && match == `"`+f.SHA256+`"` { w.WriteHeader(http.StatusNotModified) return diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go new file mode 100644 index 0000000..29fc8aa --- /dev/null +++ b/backend/internal/api/api_test.go @@ -0,0 +1,96 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "oneblog/internal/auth" + "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) + } + a := &API{ + Store: st, + Cfg: &config.Config{SiteURL: "http://localhost:8080"}, + ReaderSessions: auth.NewReaderSessions("test-secret", time.Hour), + TG: auth.Telegram{Bot: "testbot", Token: "123:abc"}, + } + settings, err := st.GetSettings() + if err != nil { + t.Fatalf("settings: %v", err) + } + settings.CommentsEnabled = true + if err := st.UpdateSettings(settings); err != nil { + t.Fatalf("enable comments: %v", err) + } + return a, a.Routes() +} + +// Telegram 伪造签名反复重试应触发 IP 限速 +func TestTelegramAuthRateLimited(t *testing.T) { + _, h := newTestAPI(t) + body := `{"id":1,"first_name":"x","hash":"deadbeef"}` + var rec *httptest.ResponseRecorder + for i := 0; i < maxAuthFails; i++ { + req := httptest.NewRequest(http.MethodPost, "/api/auth/telegram", strings.NewReader(body)) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("attempt %d: got %d, want 403", i+1, rec.Code) + } + } + req := httptest.NewRequest(http.MethodPost, "/api/auth/telegram", strings.NewReader(body)) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("after %d failures: got %d, want 429", maxAuthFails, rec.Code) + } +} + +// 评论写入按读者限速:第 maxComments+1 条被拒 +func TestCommentRateLimited(t *testing.T) { + a, h := newTestAPI(t) + p, err := a.Store.Create(model.PostInput{Kind: model.KindLong, Title: "t", Slug: "t", + ContentMd: "x", Status: model.StatusPublished}) + if err != nil { + t.Fatal(err) + } + rd, err := a.Store.UpsertReader(model.Reader{Provider: "github", Handle: "u1", Name: "u1"}) + if err != nil { + t.Fatal(err) + } + tok, _ := a.ReaderSessions.Issue(rd.ID) + post := func(i int) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]any{"post_id": p.ID, "body_md": "好"}) + req := httptest.NewRequest(http.MethodPost, "/api/comments", strings.NewReader(string(body))) + req.AddCookie(&http.Cookie{Name: auth.ReaderCookie, Value: tok}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + for i := 0; i < maxComments; i++ { + if rec := post(i); rec.Code != http.StatusCreated { + t.Fatalf("comment %d: got %d %s", i+1, rec.Code, rec.Body.String()) + } + } + if rec := post(maxComments); rec.Code != http.StatusTooManyRequests { + t.Fatalf("over limit: got %d, want 429", rec.Code) + } +} diff --git a/backend/internal/api/comments.go b/backend/internal/api/comments.go index c545d1f..9e467cc 100644 --- a/backend/internal/api/comments.go +++ b/backend/internal/api/comments.go @@ -18,6 +18,7 @@ import ( "oneblog/internal/auth" "oneblog/internal/httpx" "oneblog/internal/model" + "oneblog/internal/ratelimit" "oneblog/internal/render" "oneblog/internal/store" ) @@ -121,18 +122,26 @@ func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) { httpx.NotFound(w) return } + ip := ratelimit.SourceKey(r) + if a.authFails.Blocked(ip) { + httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试") + return + } ck, err := r.Cookie(oauthStateCook) if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") { + a.authFails.Add(ip) httpx.BadRequest(w, "state 不匹配,请重新登录") return } gh, err := a.GH.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/github") if err != nil { + a.authFails.Add(ip) httpx.ServerError(w, err) return } u, err := a.GH.FetchUser(r.Context(), gh) if err != nil { + a.authFails.Add(ip) httpx.ServerError(w, err) return } @@ -206,6 +215,15 @@ func (a *API) createComment(w http.ResponseWriter, r *http.Request) { httpx.Error(w, http.StatusForbidden, "你已被禁言,暂时无法评论") return } + // 写入限速:每读者每分钟最多 maxComments 条(站主豁免,批量回复不该被卡) + var rateKey string + if !isOwner { + rateKey = strconv.FormatInt(reader.ID, 10) + if a.commentNew.Blocked(rateKey) { + httpx.Error(w, http.StatusTooManyRequests, "发得太快了,休息一分钟再试") + return + } + } st, err := a.Store.GetSettings() if err != nil { httpx.ServerError(w, err) @@ -267,6 +285,9 @@ func (a *API) createComment(w http.ResponseWriter, r *http.Request) { httpx.ServerError(w, err) return } + if rateKey != "" { + a.commentNew.Add(rateKey) // 只计成功写入:空正文这类手滑不扣配额 + } httpx.Created(w, c) } diff --git a/backend/internal/api/providers.go b/backend/internal/api/providers.go index a736cb6..7139e7f 100644 --- a/backend/internal/api/providers.go +++ b/backend/internal/api/providers.go @@ -14,6 +14,7 @@ import ( "oneblog/internal/auth" "oneblog/internal/httpx" "oneblog/internal/model" + "oneblog/internal/ratelimit" ) // authProviders 列出已配置的登录方式。 @@ -79,18 +80,26 @@ func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) { httpx.NotFound(w) return } + ip := ratelimit.SourceKey(r) + if a.authFails.Blocked(ip) { + httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试") + return + } ck, err := r.Cookie(oauthStateCook) if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") { + a.authFails.Add(ip) httpx.BadRequest(w, "state 不匹配,请重新登录") return } accessToken, err := a.GG.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/google") if err != nil { + a.authFails.Add(ip) httpx.ServerError(w, err) return } u, err := a.GG.FetchUser(r.Context(), accessToken) if err != nil { + a.authFails.Add(ip) httpx.ServerError(w, err) return } @@ -121,6 +130,12 @@ func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) { httpx.NotFound(w) return } + // widget 回传是纯表单 POST,签名可被伪造重放——失败计数最必要的一路 + ip := ratelimit.SourceKey(r) + if a.authFails.Blocked(ip) { + httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试") + return + } // 原样读 body:验签必须用收到的全部字段(官方规则), // 身份字段再单独解一次 body, err := io.ReadAll(r.Body) @@ -130,10 +145,12 @@ func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) { } var fields map[string]any if err := json.Unmarshal(body, &fields); err != nil || len(fields) == 0 { + a.authFails.Add(ip) httpx.BadRequest(w, "invalid body") return } if err := a.TG.VerifyMap(fields); err != nil { + a.authFails.Add(ip) httpx.Error(w, http.StatusForbidden, "Telegram 登录校验失败,请重试") return } diff --git a/backend/internal/api/thumb.go b/backend/internal/api/thumb.go new file mode 100644 index 0000000..fdcfa44 --- /dev/null +++ b/backend/internal/api/thumb.go @@ -0,0 +1,141 @@ +// 缩略图懒生成:GET /uploads/thumb/{key}?w=960 +// +// 首次请求从存储端读一次原图,缩放编码后落盘缓存(DataDir/.thumbnail_cache), +// 之后直接供缓存——时间线首屏不再拉原图。非图片 / 解码失败 / 冷却期内 +// 一律 302 回原图:前端 拿 302 是无感的。 +package api + +import ( + "errors" + "net/http" + "os" + "strconv" + "strings" + + "oneblog/internal/httpx" + "oneblog/internal/model" + "oneblog/internal/storage" + "oneblog/internal/store" + "oneblog/internal/thumbs" +) + +const ( + thumbDefaultW = 960 + thumbMinW = 64 + thumbMaxW = 1600 +) + +func (a *API) ThumbHandler() http.Handler { + return http.HandlerFunc(a.thumb) +} + +func (a *API) thumb(w http.ResponseWriter, r *http.Request) { + if a.Thumbs == nil { + httpx.NotFound(w) + return + } + key := strings.TrimPrefix(r.URL.Path, "/uploads/thumb/") + if key == "" || strings.Contains(key, "..") { + httpx.NotFound(w) + return + } + width := thumbDefaultW + if s := r.URL.Query().Get("w"); s != "" { + if n, err := strconv.Atoi(s); err == nil && n >= thumbMinW && n <= thumbMaxW { + width = n + } + } + + f, err := a.Store.GetFileByKey(key) + if errors.Is(err, store.ErrNotFound) { + httpx.NotFound(w) + return + } + if err != nil { + httpx.ServerError(w, err) + return + } + + if !thumbs.Supported(f.Mime) || f.SHA256 == "" { + a.thumbFallback(w, r, f) + return + } + + // 快路径:缓存命中直接供(锁外) + if p := a.Thumbs.FindCached(f.SHA256, width); p != "" { + a.serveThumbFile(w, r, p, f) + return + } + // 冷却期:近期失败过,不再尝试 + if a.Thumbs.FailedRecently(f.SHA256) { + a.thumbFallback(w, r, f) + return + } + + // 慢路径:同 key+宽度并发只生成一次,后来者等锁后读缓存 + lk := a.Thumbs.Lock(f.SHA256, width) + lk.Lock() + defer lk.Unlock() + if p := a.Thumbs.FindCached(f.SHA256, width); p != "" { + a.serveThumbFile(w, r, p, f) + return + } + + rc, _, err := a.Blobs.Open(r.Context(), f.Key) + if err != nil { + a.Thumbs.MarkFailed(f.SHA256) + a.thumbFallback(w, r, f) + return + } + src, err := thumbs.ReadAllLimited(rc, thumbs.MaxSrcBytes) + rc.Close() + if err != nil { + a.Thumbs.MarkFailed(f.SHA256) + a.thumbFallback(w, r, f) + return + } + out, outMime, _, err := thumbs.Generate(src, f.Mime, width) + if err != nil { + a.Thumbs.MarkFailed(f.SHA256) + a.thumbFallback(w, r, f) + return + } + p, err := a.Thumbs.Put(f.SHA256, width, out, outMime) + if err != nil { + // 写缓存失败不拖累本次响应:产物就在内存里 + a.serveThumbBytes(w, outMime, out, f) + return + } + a.serveThumbFile(w, r, p, f) +} + +// thumbFallback 回原图:R2 + 公开域名是 302 直链,本地/未配域名回 /uploads/ 路由。 +func (a *API) thumbFallback(w http.ResponseWriter, r *http.Request, f model.File) { + http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase), http.StatusFound) +} + +// serveThumbFile 用 ServeContent 供缓存文件:自带 Range/Last-Modified/304。 +func (a *API) serveThumbFile(w http.ResponseWriter, r *http.Request, path string, f model.File) { + fp, err := os.Open(path) + if err != nil { + a.thumbFallback(w, r, f) + return + } + defer fp.Close() + fi, err := fp.Stat() + if err != nil { + httpx.NotFound(w) + return + } + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + http.ServeContent(w, r, path, fi.ModTime(), fp) +} + +func (a *API) serveThumbBytes(w http.ResponseWriter, mime string, data []byte, f model.File) { + w.Header().Set("Content-Type", mime) + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + w.Header().Set("ETag", `"`+f.SHA256+`"`) + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} diff --git a/backend/internal/ratelimit/ratelimit.go b/backend/internal/ratelimit/ratelimit.go new file mode 100644 index 0000000..d97e7f6 --- /dev/null +++ b/backend/internal/ratelimit/ratelimit.go @@ -0,0 +1,73 @@ +// Package ratelimit 是一个极小的按来源 IP 滑动窗口限速器, +// 后台登录与读者登录 / 评论写入共用同一实现。 +// 只计失败/写入这类「不该高频」的事件;键取连接层地址, +// X-Forwarded-For 可伪造,不作为限速键。 +package ratelimit + +import ( + "net" + "net/http" + "sync" + "time" +) + +type Window struct { + mu sync.Mutex + hits map[string][]time.Time + max int + window time.Duration +} + +func New(max int, window time.Duration) *Window { + return &Window{hits: make(map[string][]time.Time), max: max, window: window} +} + +// Blocked 报告 key 在窗口内是否已达上限。 +func (l *Window) Blocked(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + return len(l.recent(key, time.Now())) >= l.max +} + +// Add 记录一次事件。 +func (l *Window) Add(key string) { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + l.hits[key] = append(l.recent(key, now), now) +} + +// Reset 清空 key 的计数(如登录成功后)。 +func (l *Window) Reset(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.hits, key) +} + +// recent 返回窗口内的事件时间;调用方必须持有 l.mu。 +func (l *Window) recent(key string, now time.Time) []time.Time { + hs := l.hits[key] + cut := now.Add(-l.window) + 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 +} + +// SourceKey 取连接层来源地址(IPv6 去掉端口)。 +func SourceKey(r *http.Request) string { + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} diff --git a/backend/internal/thumbs/thumbs.go b/backend/internal/thumbs/thumbs.go new file mode 100644 index 0000000..3b70c37 --- /dev/null +++ b/backend/internal/thumbs/thumbs.go @@ -0,0 +1,275 @@ +// Package thumbs 懒生成上传图片的缩略图并落盘缓存(memos 模式): +// 首次请求时从存储端读一次原图,缩放编码后进缓存,此后不再碰原文件; +// 生成失败写 .failed 标记,1 小时内不反复重试。 +package thumbs + +import ( + "bytes" + "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + // MaxSrcBytes 超过此大小的原图不生成(防止大图拖垮请求),调用方回原图。 + MaxSrcBytes = 25 << 20 // 25MB + // maxDim 单边像素上限,超过视为畸形图(解压炸弹防线)。 + maxDim = 10000 + // JPEGQuality 转码输出质量。 + JPEGQuality = 82 + // failedTTL 失败后的重试冷却。 + failedTTL = time.Hour +) + +// Supported 报告该 mime 是否走缩略图管线(标准库能完整解码的静图)。 +func Supported(mime string) bool { + switch mime { + case "image/jpeg", "image/png", "image/gif": + return true + } + return false +} + +// Generate 把原图字节缩放到宽 w(只缩不放)。已够小的原生格式图原样直通 +// (返回 passThrough=true,调用方直接落盘 src),其余统一转 JPEG—— +// 缩略图不需要 alpha,透明区铺白底。 +func Generate(src []byte, mime string, w int) (out []byte, outMime string, passThrough bool, err error) { + cfg, _, err := image.DecodeConfig(bytes.NewReader(src)) + if err != nil { + return nil, "", false, fmt.Errorf("decode config: %w", err) + } + if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxDim || cfg.Height > maxDim { + return nil, "", false, fmt.Errorf("bad dimensions %dx%d", cfg.Width, cfg.Height) + } + if cfg.Width <= w && Supported(mime) { + return src, mime, true, nil + } + + var img image.Image + switch mime { + case "image/jpeg": + img, err = jpeg.Decode(bytes.NewReader(src)) + case "image/png": + img, err = png.Decode(bytes.NewReader(src)) + case "image/gif": + img, err = gif.Decode(bytes.NewReader(src)) // 首帧,缩略图不做动画 + default: + return nil, "", false, fmt.Errorf("unsupported mime %q", mime) + } + if err != nil { + return nil, "", false, fmt.Errorf("decode: %w", err) + } + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, Resize(img, w), &jpeg.Options{Quality: JPEGQuality}); err != nil { + return nil, "", false, err + } + return buf.Bytes(), "image/jpeg", false, nil +} + +// Resize 盒均值(box-average)缩放到宽 nw(等比,只缩不放)。 +// 非预乘 RGBA 按 alpha 加权平均,透明像素不计入颜色、铺白底。纯标准库。 +func Resize(src image.Image, nw int) *image.RGBA { + b := src.Bounds() + ow, oh := b.Dx(), b.Dy() + if nw <= 0 || nw >= ow { + nw = ow + } + nh := (oh*nw + ow/2) / ow + dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) + flat := flatten(src, b) + + for y := 0; y < nh; y++ { + y0 := y * oh / nh + y1 := (y+1)*oh/nh + 1 + if y1 > oh { + y1 = oh + } + if y1 <= y0 { + y1 = y0 + 1 + } + for x := 0; x < nw; x++ { + x0 := x * ow / nw + x1 := (x+1)*ow/nw + 1 + if x1 > ow { + x1 = ow + } + if x1 <= x0 { + x1 = x0 + 1 + } + var r, g, bl, a, n uint64 + for sy := y0; sy < y1; sy++ { + row := sy * ow * 4 + for sx := x0; sx < x1; sx++ { + i := row + sx*4 + al := uint64(flat[i+3]) + r += uint64(flat[i]) * al + g += uint64(flat[i+1]) * al + bl += uint64(flat[i+2]) * al + a += al + n++ + } + } + d := y*nw*4 + x*4 + if a == 0 || n == 0 { + dst.Pix[d], dst.Pix[d+1], dst.Pix[d+2], dst.Pix[d+3] = 255, 255, 255, 255 + continue + } + dst.Pix[d] = uint8(r / a) + dst.Pix[d+1] = uint8(g / a) + dst.Pix[d+2] = uint8(bl / a) + dst.Pix[d+3] = uint8(a / n) + } + } + return dst +} + +// flatten 把任意 image.Image 转成 w*h 扁平非预乘 RGBA 字节。 +func flatten(src image.Image, b image.Rectangle) []byte { + w, h := b.Dx(), b.Dy() + out := make([]byte, w*h*4) + i := 0 + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + r, g, bl, a := src.At(x, y).RGBA() // 0..0xffff,预乘 + pa := a >> 8 + if pa == 0 { + out[i], out[i+1], out[i+2], out[i+3] = 0, 0, 0, 0 + } else { + out[i] = uint8(min255((r >> 8) * 0xff / pa)) + out[i+1] = uint8(min255((g >> 8) * 0xff / pa)) + out[i+2] = uint8(min255((bl >> 8) * 0xff / pa)) + out[i+3] = uint8(pa) + } + i += 4 + } + } + return out +} + +func min255(v uint32) uint32 { + if v > 0xff { + return 0xff + } + return v +} + +// Store 是缩略图磁盘缓存: +// +// {Dir}/{sha[:2]}/{sha[:32]}-{w}.jpg 成功产物(直通可能是 .png/.gif) +// {Dir}/{sha[:2]}/{sha[:32]}.failed 失败标记 +// +// 文件名以原图内容哈希(files.sha256)为键:内容变则键变,缓存天然失效。 +type Store struct { + Dir string + mu sync.Mutex + locks map[string]*sync.Mutex +} + +func NewStore(dir string) *Store { + return &Store{Dir: dir, locks: make(map[string]*sync.Mutex)} +} + +func (s *Store) dir(sha string) string { return filepath.Join(s.Dir, sha[:2]) } + +func (s *Store) jpegPath(sha string, w int) string { + return filepath.Join(s.dir(sha), fmt.Sprintf("%s-%d.jpg", sha[:32], w)) +} + +func (s *Store) failedPath(sha string) string { + return filepath.Join(s.dir(sha), sha[:32]+".failed") +} + +// FindCached 返回已存在的缓存文件(jpg 优先,其次直通的 png/gif),没有则空串。 +func (s *Store) FindCached(sha string, w int) string { + base := strings.TrimSuffix(s.jpegPath(sha, w), ".jpg") + for _, ext := range []string{".jpg", ".png", ".gif"} { + if fi, err := os.Stat(base + ext); err == nil && !fi.IsDir() { + return base + ext + } + } + return "" +} + +// Put 原子写入缓存文件(内容直通时保留原生格式),成功则清掉失败标记。 +func (s *Store) Put(sha string, w int, data []byte, mime string) (string, error) { + p := s.jpegPath(sha, w) + if mime != "image/jpeg" { + p = strings.TrimSuffix(p, ".jpg") + extFor(mime) + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return "", err + } + tmp := p + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return "", err + } + if err := os.Rename(tmp, p); err != nil { + os.Remove(tmp) + return "", err + } + _ = os.Remove(s.failedPath(sha)) + return p, nil +} + +// FailedRecently 报告是否在冷却期(近期生成失败过)。 +func (s *Store) FailedRecently(sha string) bool { + fi, err := os.Stat(s.failedPath(sha)) + return err == nil && time.Since(fi.ModTime()) < failedTTL +} + +// MarkFailed 落失败标记。 +func (s *Store) MarkFailed(sha string) { + if err := os.MkdirAll(s.dir(sha), 0o755); err == nil { + _ = os.WriteFile(s.failedPath(sha), []byte("thumbs: generation failed\n"), 0o644) + } +} + +// Lock 取该(哈希, 宽度)的互斥锁:并发首请求只生成一次,其余等待后读缓存。 +func (s *Store) Lock(sha string, w int) *sync.Mutex { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.locks) > lockCacheMax { + clear(s.locks) // 粗粒度回收:正在持有的锁不受影响(调用方拿着指针), + } // 清掉的只是历史条目,最坏情况是同一图并发重生成一次 + k := fmt.Sprintf("%s-%d", sha[:32], w) + l, ok := s.locks[k] + if !ok { + l = &sync.Mutex{} + s.locks[k] = l + } + return l +} + +// lockCacheMax 限制锁表大小(每张图最多 3 种宽度),博客体量下足够。 +const lockCacheMax = 4096 + +func extFor(mime string) string { + switch mime { + case "image/png": + return ".png" + case "image/gif": + return ".gif" + } + return ".jpg" +} + +// ReadAllLimited 读至多 max 字节,超限报错(防止把 25MB+ 原图吞进内存)。 +func ReadAllLimited(r io.Reader, max int64) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, max+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > max { + return nil, fmt.Errorf("source exceeds %d bytes", max) + } + return data, nil +} diff --git a/backend/internal/thumbs/thumbs_test.go b/backend/internal/thumbs/thumbs_test.go new file mode 100644 index 0000000..7209ec1 --- /dev/null +++ b/backend/internal/thumbs/thumbs_test.go @@ -0,0 +1,94 @@ +package thumbs + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "image/png" + "testing" +) + +func makePNG(t *testing.T, w, h int, transparent bool) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + // 左红右绿两半,方便验证盒均值没把通道混错 + c := color.RGBA{255, 0, 0, 255} + if x >= w/2 { + c = color.RGBA{0, 255, 0, 255} + } + if transparent { + c = color.RGBA{0, 0, 0, 0} + } + img.Set(x, y, c) + } + } + var b bytes.Buffer + if err := png.Encode(&b, img); err != nil { + t.Fatal(err) + } + return b.Bytes() +} + +func TestGenerateDownscale(t *testing.T) { + src := makePNG(t, 800, 400, false) + out, mime, pass, err := Generate(src, "image/png", 400) + if err != nil { + t.Fatal(err) + } + if pass { + t.Fatal("800px 图在 w=400 不应直通") + } + if mime != "image/jpeg" { + t.Fatalf("mime=%s", mime) + } + cfg, _, err := image.DecodeConfig(bytes.NewReader(out)) + if err != nil { + t.Fatal(err) + } + if cfg.Width != 400 || cfg.Height != 200 { + t.Fatalf("got %dx%d, want 400x200", cfg.Width, cfg.Height) + } + img, err := jpeg.Decode(bytes.NewReader(out)) + if err != nil { + t.Fatal(err) + } + // 盒均值后左右两半的中心仍是红/绿(容差给 JPEG 质量 82 留余量) + r, g, _, _ := img.At(100, 100).RGBA() + if r>>8 < 200 || g>>8 > 60 { + t.Fatalf("左半应偏红: r=%d g=%d", r>>8, g>>8) + } + r, g, _, _ = img.At(300, 100).RGBA() + if g>>8 < 200 || r>>8 > 60 { + t.Fatalf("右半应偏绿: r=%d g=%d", r>>8, g>>8) + } +} + +func TestGeneratePassThrough(t *testing.T) { + src := makePNG(t, 300, 150, false) + out, mime, pass, err := Generate(src, "image/png", 960) + if err != nil { + t.Fatal(err) + } + if !pass || mime != "image/png" || !bytes.Equal(out, src) { + t.Fatalf("小图应原样直通 pass=%v mime=%s", pass, mime) + } +} + +func TestGenerateRejectsGarbage(t *testing.T) { + if _, _, _, err := Generate([]byte("not an image"), "image/jpeg", 400); err == nil { + t.Fatal("垃圾字节应报错") + } +} + +func TestFlattenAlphaUnpremultiply(t *testing.T) { + // 50% 透明的纯红:alpha=128 预乘 R=128,还原应为 R≈255 + img := image.NewNRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.NRGBA{255, 0, 0, 128}) + flat := flatten(img, img.Bounds()) + if flat[0] < 240 || flat[3] != 128 { + t.Fatalf("unpremultiply wrong: r=%d a=%d", flat[0], flat[3]) + } +} diff --git a/backend/main.go b/backend/main.go index 904127f..e4ffd95 100644 --- a/backend/main.go +++ b/backend/main.go @@ -23,6 +23,7 @@ import ( "oneblog/internal/db" "oneblog/internal/storage" "oneblog/internal/store" + "oneblog/internal/thumbs" ) func main() { @@ -65,11 +66,14 @@ func main() { readerSessions := auth.NewReaderSessions(cfg.SessionSec, 30*24*time.Hour) // 后台管理员会话(cookie one_session),前台评论也用它识别站主身份 adminSessions := admin.NewSessions(cfg.SessionSec, 7*24*time.Hour) + // 缩略图懒生成 + 磁盘缓存(首次请求生成一次,之后直接供缓存) + thumbCache := thumbs.NewStore(filepath.Join(cfg.DataDir, ".thumbnail_cache")) public := &api.API{ Store: st, Cfg: cfg, Blobs: blobs, + Thumbs: thumbCache, ReaderSessions: readerSessions, GH: auth.GitHub{ClientID: cfg.GitHubClientID, ClientSecret: cfg.GitHubClientSecret}, GG: auth.Google{ClientID: cfg.GoogleClientID, ClientSecret: cfg.GoogleClientSecret}, @@ -78,11 +82,12 @@ func main() { } adminAPI := admin.NewAPI(st, cfg, adminSessions) adminAPI.Blobs = blobs - adminAPI.Blobs = blobs root := http.NewServeMux() root.Handle("/api/admin/", adminAPI.Routes()) public.Mount(root) + // 更长的前缀优先匹配:/uploads/thumb/ 走缩略图,其余 /uploads/ 走原图流式 + root.Handle("/uploads/thumb/", public.ThumbHandler()) root.Handle("/uploads/", public.UploadsHandler()) root.Handle("/", spaHandler(cfg.WebDist)) diff --git a/frontend/src/components/PostCard.vue b/frontend/src/components/PostCard.vue index 9e31ed8..29eb2be 100644 --- a/frontend/src/components/PostCard.vue +++ b/frontend/src/components/PostCard.vue @@ -3,7 +3,7 @@ import { computed } from 'vue' import { useRouter } from 'vue-router' import { site } from '../site' import { openLightbox } from '../lightbox' -import { relativeDate, stripTags, minutesLabel, sanitizeHtml } from '../utils' +import { relativeDate, stripTags, minutesLabel, sanitizeHtml, thumbURL, thumbifyHtml } from '../utils' const router = useRouter() @@ -20,10 +20,11 @@ function openPost() { router.push(`/post/${props.post.slug}`) } -// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要 +// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要。 +// 正文里的 换缩略图并补懒加载(详情仍是原图)。 const body = computed(() => { if (!isShort.value) return props.post.summary || stripTags(props.post.content_html || '') - return sanitizeHtml(props.post.content_html) + return sanitizeHtml(thumbifyHtml(props.post.content_html)) }) // 长文没写摘要时,从正文里截一段纯文本 @@ -33,6 +34,9 @@ const summaryText = computed(() => { }) const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUpperCase()) + +// 配图缩略图宽度:单图全宽格取宽一些,多图小格 480 足够(2x 屏也清晰) +const gridW = computed(() => (props.post.images && props.post.images.length === 1 ? 720 : 480))