mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
241 lines
8.3 KiB
Go
241 lines
8.3 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// transparent1x1PNG is a baseline 67-byte PNG used as a fallback when the
|
|
// upstream image cannot be retrieved, so browser layouts never collapse.
|
|
var transparent1x1PNG = []byte{
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
|
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
|
|
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
|
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
|
|
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
|
0x42, 0x60, 0x82,
|
|
}
|
|
|
|
// detectContentType returns the MIME type of data using the first 512 bytes.
|
|
func detectContentType(data []byte) string {
|
|
if len(data) > 512 {
|
|
return http.DetectContentType(data[:512])
|
|
}
|
|
return http.DetectContentType(data)
|
|
}
|
|
|
|
func isImageContentType(ctype string) bool {
|
|
ctype = strings.ToLower(strings.TrimSpace(strings.Split(ctype, ";")[0]))
|
|
return strings.HasPrefix(ctype, "image/")
|
|
}
|
|
|
|
func validImageContentType(data []byte) (string, bool) {
|
|
detected := detectContentType(data)
|
|
if isImageContentType(detected) && !isTransparentPlaceholderData(data) {
|
|
return detected, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func isTransparentPlaceholderData(data []byte) bool {
|
|
return bytes.Equal(data, transparent1x1PNG)
|
|
}
|
|
|
|
// servePlaceholder writes a 1x1 transparent PNG to w. Used as a fallback
|
|
// when upstream fetch fails so the browser layout stays intact.
|
|
func servePlaceholder(w http.ResponseWriter) {
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(transparent1x1PNG)
|
|
}
|
|
|
|
func serveCachedPlaceholder(w http.ResponseWriter) {
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Cache-Control", imagePlaceholderCacheControl)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(transparent1x1PNG)
|
|
}
|
|
|
|
// remoteImageCachePaths 返回上游原图池的路径(带 URL 校验)。
|
|
func (p *ImageProxy) remoteImageCachePaths(raw string) (string, string, string, error) {
|
|
if _, err := p.validateURL(raw); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
key, cachePath, failPath := p.remoteImageCachePathsForValidated(raw)
|
|
return key, cachePath, failPath, nil
|
|
}
|
|
|
|
// remoteImageCachePathsForValidated 返回上游原图池的缓存路径。
|
|
func (p *ImageProxy) remoteImageCachePathsForValidated(raw string) (string, string, string) {
|
|
return p.remoteImageCachePathsInPool(raw, imageOriginalCacheSubdir)
|
|
}
|
|
|
|
// remoteImageCachePathsInPool 生成某个池内的缓存路径。池决定清理策略:
|
|
// 原图池小配额 + 短保留,成品池(挂载 Emby 按尺寸产出的图)长期保留。
|
|
func (p *ImageProxy) remoteImageCachePathsInPool(raw, pool string) (string, string, string) {
|
|
sum := sha256.Sum256([]byte(imageCacheKeyURL(raw)))
|
|
key := hex.EncodeToString(sum[:])
|
|
cachePath := filepath.Join(p.cacheDir, pool, key)
|
|
return key, cachePath, cachePath + ".fail"
|
|
}
|
|
|
|
// remoteImageCachePathsEveryPool 返回同一个地址在所有池中的缓存路径。调用方
|
|
// 只知道原始 URL,无法判断它是原图还是挂载 Emby 按尺寸返回的成品,因此清理
|
|
// 类操作(refresh/retry)需要两个池都试一遍。
|
|
func (p *ImageProxy) remoteImageCachePathsEveryPool(raw string) [][3]string {
|
|
pools := []string{imageOriginalCacheSubdir, imageRenditionCacheSubdir}
|
|
out := make([][3]string, 0, len(pools))
|
|
for _, pool := range pools {
|
|
key, cachePath, failPath := p.remoteImageCachePathsInPool(raw, pool)
|
|
out = append(out, [3]string{key, cachePath, failPath})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// imageProxyMaxDownloadBytes 是单张图片的下载上限,防上游异常返回超大响应
|
|
// 把磁盘和内存打满。
|
|
const imageProxyMaxDownloadBytes = 32 << 20
|
|
|
|
var (
|
|
// errImageCacheUnavailable 表示缓存目录不可写、连临时文件都建不出来。
|
|
// 它发生在读取响应体之前,因此调用方还能退回内存缓冲,保证图片不因
|
|
// 运维异常(磁盘只读/满)全部变成占位图。
|
|
errImageCacheUnavailable = errors.New("image cache is not writable")
|
|
// errImageCacheCommitFailed 表示响应体已被消费但无法提交到缓存(写入
|
|
// 中断、rename 失败等),此时无法重放响应体,只能按拉取失败处理。
|
|
errImageCacheCommitFailed = errors.New("image cache commit failed")
|
|
)
|
|
|
|
// cachedImageFileValid 只读取文件头判断缓存文件是否是可用图片。透明占位图
|
|
// 与空文件都视为不可用(历史实现曾把上游失败时的占位图写进缓存)。
|
|
func cachedImageFileValid(path string) error {
|
|
file, err := os.Open(path) // #nosec G304 -- cache paths are SHA-derived under cacheDir.
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
stat, err := file.Stat()
|
|
if err != nil || stat.IsDir() || stat.Size() <= 0 {
|
|
return errImageProxyNonImageContent
|
|
}
|
|
headerSize := 512
|
|
if stat.Size() < int64(headerSize) {
|
|
headerSize = int(stat.Size())
|
|
}
|
|
header := make([]byte, headerSize)
|
|
n, readErr := io.ReadFull(file, header)
|
|
if readErr != nil && !errors.Is(readErr, io.ErrUnexpectedEOF) {
|
|
return readErr
|
|
}
|
|
header = header[:n]
|
|
if !isImageContentType(detectContentType(header)) {
|
|
return errImageProxyNonImageContent
|
|
}
|
|
// 透明占位图恰好 67 字节;只看头部就够(detectContentType 也只读前 512 字节)。
|
|
if n == len(transparent1x1PNG) && bytes.Equal(header, transparent1x1PNG) {
|
|
return errImageProxyNonImageContent
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// streamImageToCache 把上游响应体流式写入临时文件,校验确为图片后原子替换到
|
|
// cachePath。原图常有数兆字节,旧实现每次都要先整张读进内存再写盘,在小内存
|
|
// 主机上几个并发海报请求就能把内存顶满。
|
|
func (p *ImageProxy) streamImageToCache(cachePath, failPath string, body io.Reader) error {
|
|
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
|
|
p.warn("imageproxy: cache dir failed", err)
|
|
return errImageCacheUnavailable
|
|
}
|
|
tmp, err := os.CreateTemp(p.cacheDir, "img-*.tmp")
|
|
if err != nil {
|
|
p.warn("imageproxy: cache temp file failed", err)
|
|
return errImageCacheUnavailable
|
|
}
|
|
tmpName := tmp.Name()
|
|
discard := func() { _ = os.Remove(tmpName) }
|
|
|
|
written, copyErr := io.Copy(tmp, io.LimitReader(body, imageProxyMaxDownloadBytes))
|
|
closeErr := tmp.Close()
|
|
if copyErr != nil || closeErr != nil {
|
|
discard()
|
|
if copyErr != nil {
|
|
return copyErr
|
|
}
|
|
return fmt.Errorf("%w: %v", errImageCacheCommitFailed, closeErr)
|
|
}
|
|
if written == 0 {
|
|
discard()
|
|
return errors.New("upstream image body is empty")
|
|
}
|
|
if err := cachedImageFileValid(tmpName); err != nil {
|
|
discard()
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpName, cachePath); err != nil {
|
|
discard()
|
|
return fmt.Errorf("%w: %v", errImageCacheCommitFailed, err)
|
|
}
|
|
_ = os.Remove(failPath)
|
|
return nil
|
|
}
|
|
|
|
func serveCachedImageFile(w http.ResponseWriter, r *http.Request, key, cachePath string) bool {
|
|
return serveImageFile(w, r, key, cachePath, imageBrowserCacheControl)
|
|
}
|
|
|
|
func serveImageFile(w http.ResponseWriter, r *http.Request, key, path, cacheControl string) bool {
|
|
file, err := os.Open(path) // #nosec G304 -- caller only passes validated local paths or SHA-derived cache paths.
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer file.Close()
|
|
stat, err := file.Stat()
|
|
if err != nil || stat.IsDir() || stat.Size() <= 0 {
|
|
return false
|
|
}
|
|
var sample [512]byte
|
|
n, _ := file.Read(sample[:])
|
|
_, _ = file.Seek(0, io.SeekStart)
|
|
ctype := detectContentType(sample[:n])
|
|
if !isImageContentType(ctype) {
|
|
return false
|
|
}
|
|
w.Header().Set("Content-Type", ctype)
|
|
w.Header().Set("Cache-Control", cacheControl)
|
|
w.Header().Set("ETag", imageFileETag(key, stat))
|
|
http.ServeContent(w, r, key, stat.ModTime(), file)
|
|
return true
|
|
}
|
|
|
|
func imageFileETag(key string, stat os.FileInfo) string {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
key = "image"
|
|
}
|
|
sum := sha256.Sum256([]byte(key))
|
|
return `"img-` + hex.EncodeToString(sum[:8]) + "-" + strconv.FormatInt(stat.Size(), 16) + "-" + strconv.FormatInt(stat.ModTime().Unix(), 16) + `"`
|
|
}
|
|
|
|
func (p *ImageProxy) markImageFetchFailed(failPath string) {
|
|
if err := os.MkdirAll(filepath.Dir(failPath), 0o750); err != nil {
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
_ = os.WriteFile(failPath, []byte(time.Now().Format(time.RFC3339Nano)), 0o600)
|
|
}
|