- 新增 /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 条/分钟
(站主豁免,且只计成功写入)
142 lines
3.6 KiB
Go
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)
|
|
}
|