mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
136 lines
4.6 KiB
Go
136 lines
4.6 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 (
|
||
"errors"
|
||
"net"
|
||
"net/http"
|
||
"path/filepath"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
|
||
"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
|
||
|
||
// 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
|
||
}
|
||
|
||
const (
|
||
imageBrowserCacheControl = "public, max-age=2592000, immutable"
|
||
imagePlaceholderCacheControl = "no-store"
|
||
)
|
||
|
||
// NewImageProxy is the constructor.
|
||
func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||
// 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()
|
||
if proxyConfiguredForImageFetch() {
|
||
// 走本地代理(如 127.0.0.1:7890)时,拨号目标是代理本身,
|
||
// 连接层 SSRF 校验会误杀本地回环代理;此时沿用 URL 级校验。
|
||
log.Info("image proxy: outbound proxy detected, connection-level SSRF guard disabled")
|
||
} else {
|
||
// 仅 URL 解析层的 isPrivateHost 可被十进制/十六进制 IP、解析到
|
||
// 私网的域名与 DNS rebinding 绕过;在拨号层对最终连接 IP 做二次
|
||
// 校验(含重定向后的每条连接)堵住该旁路。
|
||
dialer := &net.Dialer{
|
||
Timeout: 15 * time.Second,
|
||
Control: func(_, address string, _ syscall.RawConn) error {
|
||
host, _, err := net.SplitHostPort(address)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
ip := net.ParseIP(host)
|
||
if ip == nil {
|
||
return errors.New("image proxy: refusing non-IP dial target")
|
||
}
|
||
if isPrivateIP(ip) {
|
||
return errors.New("image proxy: requests to private/internal hosts are not allowed")
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
transport.DialContext = dialer.DialContext
|
||
}
|
||
return &ImageProxy{
|
||
cfg: cfg,
|
||
log: log,
|
||
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
|
||
client: &http.Client{Timeout: 30 * time.Second, Transport: transport},
|
||
}
|
||
}
|
||
|
||
// proxyConfiguredForImageFetch 探测环境变量或系统代理是否会影响图片抓取。
|
||
func proxyConfiguredForImageFetch() bool {
|
||
req, err := http.NewRequest(http.MethodGet, "https://image.tmdb.org/", nil)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
proxy, err := ProxyFromEnvironmentOrSystem(req)
|
||
return err == nil && proxy != nil
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// Prune removes oldest cached images until disk usage is within the configured limit.
|
||
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
|
||
if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 {
|
||
return PruneImageCacheResult{}, nil
|
||
}
|
||
maxBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
|
||
return PruneImageCache(p.cacheDir, maxBytes)
|
||
}
|