缩略图懒生成 + 评论登录限流:时间线不再拉原图
- 新增 /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 条/分钟
(站主豁免,且只计成功写入)
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user