Files
ONE/backend/internal/api/thumb.go
T
Sakurasan 6108aca34c 缩略图懒生成 + 评论登录限流:时间线不再拉原图
- 新增 /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 条/分钟
  (站主豁免,且只计成功写入)
2026-09-28 13:34:19 +08:00

142 lines
3.6 KiB
Go

// 缩略图懒生成:GET /uploads/thumb/{key}?w=960
//
// 首次请求从存储端读一次原图,缩放编码后落盘缓存(DataDir/.thumbnail_cache),
// 之后直接供缓存——时间线首屏不再拉原图。非图片 / 解码失败 / 冷却期内
// 一律 302 回原图:前端 <img> 拿 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)
}