mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
218 lines
7.8 KiB
Go
218 lines
7.8 KiB
Go
// Package service — image proxy.
|
||
//
|
||
// Some deployments cannot reach image.tmdb.org directly (GFW, internal-only
|
||
// networks). ImageProxy fronts a remote image URL so the browser only ever
|
||
// talks to the MeBox origin. The proxy:
|
||
//
|
||
// - validates the URL scheme is http/https,
|
||
// - streams bytes through with a small disk cache under cache/images,
|
||
// - falls back to a transparent 1×1 PNG on upstream failure so the UI
|
||
// never breaks layout,
|
||
// - honors HTTP(S)_PROXY environment variables so users behind GFW can
|
||
// route image fetches through their proxy.
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"net"
|
||
"net/http"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
"golang.org/x/sync/singleflight"
|
||
|
||
"github.com/truewhile/MeBox/internal/config"
|
||
)
|
||
|
||
// ImageProxy fetches and caches remote images on behalf of the browser.
|
||
type ImageProxy struct {
|
||
cfg *config.Config
|
||
log *zap.Logger
|
||
client *http.Client
|
||
cacheDir string
|
||
mu sync.Mutex
|
||
fetchGroup singleflight.Group
|
||
|
||
// directClient bypasses HTTP_PROXY / OS proxy settings. It is built once and
|
||
// shared: rebuilding the transport on every fetch discarded all keep-alive
|
||
// connections, so every burst of poster requests paid a fresh TCP (and TLS)
|
||
// handshake per image.
|
||
directClient *http.Client
|
||
|
||
// resizeSem bounds concurrent decode/resize jobs. Emby TV clients request
|
||
// poster grids in bursts; letting every request decode a source image at
|
||
// once causes CPU and memory spikes that make the whole UI feel sluggish.
|
||
resizeSemMu sync.Mutex
|
||
resizeSem chan struct{}
|
||
|
||
// resizeQueueWait 是一次请求最多为一格解码槽排队的时长。为 0 时用
|
||
// defaultResizeQueueWait;测试会把它调小以免真的等待两秒。
|
||
resizeQueueWait time.Duration
|
||
|
||
// libraryRootsFn returns the configured media library roots so that
|
||
// sidecar poster/artwork files stored alongside media (under arbitrary
|
||
// per-library paths) are allowed by isAllowedLocalPath. It is provided
|
||
// by the service container after construction and may be nil in tests.
|
||
libraryRootsFn func() []string
|
||
libRootsMu sync.Mutex
|
||
libRootsCache []string
|
||
libRootsAt time.Time
|
||
|
||
// allowedRemoteHostsFn returns hostnames or IPs of explicitly configured
|
||
// upstream services (e.g. remote Emby mounts) that should bypass SSRF private IP checks.
|
||
allowedRemoteHostsFn func() []string
|
||
allowedHostsMu sync.Mutex
|
||
allowedHostsCache map[string]bool
|
||
allowedHostsAt time.Time
|
||
|
||
// remoteEmbyTokenFn / refreshRemoteEmbyTokenFn let the proxy carry a fresh
|
||
// credential for configured remote-Emby mounts. Image URLs are minted from
|
||
// whatever token the account had when the payload was built, so a URL can
|
||
// outlive its api_key; these hooks re-supply the current one and force a
|
||
// re-login when the mount rejects it.
|
||
remoteEmbyTokenFn func(ctx context.Context, host string) (string, bool)
|
||
refreshRemoteEmbyTokenFn func(ctx context.Context, host string) (string, error)
|
||
remoteEmbyTokenMu sync.Mutex
|
||
remoteEmbyTokenCache map[string]remoteEmbyImageToken
|
||
}
|
||
|
||
const (
|
||
imageBrowserCacheControl = "public, max-age=2592000, immutable"
|
||
imagePlaceholderCacheControl = "no-store"
|
||
imageMaxResizeConcurrency = 4
|
||
|
||
// imageOriginalCacheSubdir 存放上游原图(生成各种尺寸的原料,可再生)。
|
||
imageOriginalCacheSubdir = "originals"
|
||
// imageRenditionCacheSubdir 存放挂载 Emby 直接按尺寸产出的成品图:它们
|
||
// 已经是客户端要的最终尺寸,和派生缩略图一样属于长期保留的热路径缓存。
|
||
imageRenditionCacheSubdir = "renditions"
|
||
|
||
// defaultResizeQueueWait 是请求为一格解码槽排队的最长时间。一张 3MP
|
||
// 海报的解码+缩放约 0.15s,2 核机器两格槽位足以在几秒内消化一整屏
|
||
// 海报;等待远好于「槽位忙就回退多兆字节原图」(那样每张图在外部网络
|
||
// 下要十几秒)。超时后仍回退原图,保证请求不会被无限期挂住。
|
||
defaultResizeQueueWait = 3 * time.Second
|
||
)
|
||
|
||
// NewImageProxy is the constructor.
|
||
func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||
proxy := &ImageProxy{
|
||
cfg: cfg,
|
||
log: log,
|
||
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
|
||
}
|
||
proxy.resizeSem = make(chan struct{}, imageResizeConcurrency())
|
||
|
||
// Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull
|
||
// from image.tmdb.org via their HTTP proxy without extra config. On
|
||
// Windows we also honor the current user's system proxy settings.
|
||
transport := NewExternalTransport()
|
||
proxy.guardTransport(transport)
|
||
directTransport := NewInternalTransport()
|
||
proxy.guardTransport(directTransport)
|
||
proxy.client = &http.Client{Timeout: 30 * time.Second, Transport: transport}
|
||
proxy.directClient = &http.Client{Timeout: 30 * time.Second, Transport: directTransport}
|
||
return proxy
|
||
}
|
||
|
||
// SetLibraryRootsProvider injects a callback that returns the current set of
|
||
// media library root directories. Sidecar posters live under these roots
|
||
// (which are arbitrary, user-defined, and not necessarily under the
|
||
// configured movies/tv/anime dirs), so they must be treated as allowed
|
||
// local-image locations.
|
||
func (p *ImageProxy) SetLibraryRootsProvider(fn func() []string) {
|
||
p.libraryRootsFn = fn
|
||
}
|
||
|
||
// libraryRoots returns the cached library roots, refreshing at most every
|
||
// 30 seconds to avoid a DB hit per image request (posters load in bulk).
|
||
func (p *ImageProxy) libraryRoots() []string {
|
||
if p.libraryRootsFn == nil {
|
||
return nil
|
||
}
|
||
p.libRootsMu.Lock()
|
||
defer p.libRootsMu.Unlock()
|
||
if p.libRootsCache != nil && time.Since(p.libRootsAt) < 30*time.Second {
|
||
return p.libRootsCache
|
||
}
|
||
p.libRootsCache = p.libraryRootsFn()
|
||
p.libRootsAt = time.Now()
|
||
return p.libRootsCache
|
||
}
|
||
|
||
// SetAllowedRemoteHostsProvider injects a callback that returns hostnames or IPs
|
||
// of explicitly configured remote services (e.g. remote Emby mounts). Requests to
|
||
// these hosts bypass SSRF private-IP restrictions.
|
||
func (p *ImageProxy) SetAllowedRemoteHostsProvider(fn func() []string) {
|
||
p.allowedRemoteHostsFn = fn
|
||
}
|
||
|
||
func (p *ImageProxy) isAllowedRemoteHost(host string) bool {
|
||
if p == nil || p.allowedRemoteHostsFn == nil {
|
||
return false
|
||
}
|
||
host = strings.ToLower(strings.TrimSpace(host))
|
||
if host == "" {
|
||
return false
|
||
}
|
||
// Strip port if present
|
||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||
host = strings.ToLower(strings.TrimSpace(h))
|
||
}
|
||
|
||
p.allowedHostsMu.Lock()
|
||
defer p.allowedHostsMu.Unlock()
|
||
if p.allowedHostsCache == nil || time.Since(p.allowedHostsAt) >= 30*time.Second {
|
||
rawList := p.allowedRemoteHostsFn()
|
||
cache := make(map[string]bool, len(rawList))
|
||
for _, item := range rawList {
|
||
item = strings.ToLower(strings.TrimSpace(item))
|
||
if item == "" {
|
||
continue
|
||
}
|
||
if h, _, err := net.SplitHostPort(item); err == nil {
|
||
item = strings.ToLower(strings.TrimSpace(h))
|
||
}
|
||
cache[item] = true
|
||
}
|
||
p.allowedHostsCache = cache
|
||
p.allowedHostsAt = time.Now()
|
||
}
|
||
return p.allowedHostsCache[host]
|
||
}
|
||
|
||
// imageResizeConcurrency keeps decode/resize concurrency within the number
|
||
// of CPU threads the process is allowed to use, capped to avoid large
|
||
// temporary RGBA buffers on tiny hosts.
|
||
func imageResizeConcurrency() int {
|
||
n := runtime.GOMAXPROCS(0)
|
||
if n < 1 {
|
||
n = 1
|
||
}
|
||
if n > imageMaxResizeConcurrency {
|
||
n = imageMaxResizeConcurrency
|
||
}
|
||
return n
|
||
}
|
||
|
||
// Prune 只执行图片原图子配额与旧缓存清理;整个磁盘缓存的总量淘汰由调度器的
|
||
// cache_cleanup 任务(PruneCache)负责。
|
||
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
|
||
if p.cfg == nil {
|
||
return PruneImageCacheResult{}, nil
|
||
}
|
||
var originalsMaxBytes int64
|
||
if p.cfg.Cache.ImagesOriginalsMaxSizeMB > 0 {
|
||
originalsMaxBytes = int64(p.cfg.Cache.ImagesOriginalsMaxSizeMB) * 1024 * 1024
|
||
}
|
||
if originalsMaxBytes <= 0 {
|
||
return PruneImageCacheResult{}, nil
|
||
}
|
||
pools := ImageCachePools(p.cacheDir, originalsMaxBytes)
|
||
return PruneImageCachePools(pools, 0)
|
||
}
|