Files
ONE/backend/internal/api/thumb.go
T
Sakurasan e68400b389 系统设置迁入后台:站点地址 / OAuth 凭据 / R2 存储 / 管理员账号,保存即生效无需重启
- 生效规则统一为「后台填了用后台的,没填回落环境变量」,老部署不改 env 照常跑
- config.Resolver:短缓存解析有效配置,存储后端按配置签名热重建;后台保存主动失效
- 秘密项(client secret / bot token / R2 密钥 / 管理员密码)接口永不回显明文,
  只报「是否已配置、来自哪里」;留空保存 = 保持现值
- 管理员密码 bcrypt 入库,DB 哈希优先、显式设置的 env 密码作解锁后路;
  后台改过密码后 admin/admin 开发模式立即失效
- 设置页新增「登录与存储」标签,基础信息加站点地址;秘密项带来源提示
- 监听地址 / 数据库 / 目录 / ONE_SECRET / Passkey 仍留环境变量(启动期依赖)
2026-10-01 12:14:10 +08:00

143 lines
3.7 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
}
a.Thumbs.Touch(path) // 记一次「最近用过」,供容量闸按 LRU 淘汰
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)
}