diff --git a/internal/handler/media.go b/internal/handler/media.go index ba11043..99e88ef 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -660,14 +660,25 @@ func streamHandler(svc *service.Container) gin.HandlerFunc { } return } - target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + // 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。 + // 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断; + // 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。 + if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") { + if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil { + if !c.Writer.Written() { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + } + } + return + } + setRedirectNoStoreHeaders(c) + c.Redirect(http.StatusFound, target) return - } - setRedirectNoStoreHeaders(c) - c.Redirect(http.StatusFound, target) - return } m, err := svc.Media.GetMedia(ctx, id) if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { diff --git a/internal/service/emby_remote.go b/internal/service/emby_remote.go index 4ef77fe..f55c177 100644 --- a/internal/service/emby_remote.go +++ b/internal/service/emby_remote.go @@ -139,6 +139,37 @@ func (r *EmbyRemoteService) ListAccounts(ctx context.Context) ([]model.StrmAccou return out, nil } +// ConfiguredRemoteHosts 返回所有已配置的远程 Emby 线路的主机名/IP(去重、不含端口)。 +func (r *EmbyRemoteService) ConfiguredRemoteHosts(ctx context.Context) []string { + if r == nil || r.repo == nil || r.repo.StrmAccount == nil { + return nil + } + accounts, err := r.ListAccounts(ctx) + if err != nil || len(accounts) == 0 { + return nil + } + seen := make(map[string]bool) + var hosts []string + for _, acct := range accounts { + lines, _, err := r.LinesOf(&acct) + if err != nil { + continue + } + for _, line := range lines { + u, err := url.Parse(line.URL) + if err != nil || u.Hostname() == "" { + continue + } + h := strings.ToLower(u.Hostname()) + if !seen[h] { + seen[h] = true + hosts = append(hosts, h) + } + } + } + return hosts +} + // AccountByID 按 ID 查找远程 Emby 挂载账号(不存在或类型不符返回 nil)。 func (r *EmbyRemoteService) AccountByID(ctx context.Context, id string) *model.StrmAccount { if strings.TrimSpace(id) == "" { diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index c3e32cd..5662594 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -17,6 +17,7 @@ import ( "net" "net/http" "path/filepath" + "strings" "sync" "syscall" "time" @@ -42,6 +43,13 @@ type ImageProxy struct { 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 } const ( @@ -51,6 +59,12 @@ const ( // 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"), + } + // 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. @@ -63,6 +77,7 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy { // 仅 URL 解析层的 isPrivateHost 可被十进制/十六进制 IP、解析到 // 私网的域名与 DNS rebinding 绕过;在拨号层对最终连接 IP 做二次 // 校验(含重定向后的每条连接)堵住该旁路。 + // 用户明确配置的远程挂载源(如内网 Emby)豁免该私网限制。 dialer := &net.Dialer{ Timeout: 15 * time.Second, Control: func(_, address string, _ syscall.RawConn) error { @@ -70,6 +85,9 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy { if err != nil { return err } + if proxy.isAllowedRemoteHost(host) { + return nil + } ip := net.ParseIP(host) if ip == nil { return errors.New("image proxy: refusing non-IP dial target") @@ -82,12 +100,9 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy { } 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}, - } + + proxy.client = &http.Client{Timeout: 30 * time.Second, Transport: transport} + return proxy } // proxyConfiguredForImageFetch 探测环境变量或系统代理是否会影响图片抓取。 @@ -125,6 +140,47 @@ func (p *ImageProxy) libraryRoots() []string { 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] +} + // 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 { diff --git a/internal/service/image_proxy_paths.go b/internal/service/image_proxy_paths.go index adca575..a3d001a 100644 --- a/internal/service/image_proxy_paths.go +++ b/internal/service/image_proxy_paths.go @@ -22,7 +22,7 @@ func (p *ImageProxy) validateURL(raw string) (*url.URL, error) { if scheme != "http" && scheme != "https" { return nil, errors.New("unsupported scheme") } - if isPrivateHost(u.Hostname()) { + if !p.isAllowedRemoteHost(u.Hostname()) && isPrivateHost(u.Hostname()) { return nil, errors.New("requests to private/internal hosts are not allowed") } return u, nil diff --git a/internal/service/image_proxy_remote_fetch.go b/internal/service/image_proxy_remote_fetch.go index 3972cba..ec3f4c2 100644 --- a/internal/service/image_proxy_remote_fetch.go +++ b/internal/service/image_proxy_remote_fetch.go @@ -51,7 +51,7 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string, p.log.Warn("imageproxy: build request failed", zap.String("url", raw), zap.Error(err)) return nil, "", "", errImageProxyRequestSetup } - applyRemoteImageHeaders(req, host) + applyRemoteImageHeaders(req, host, raw) resp, err := candidate.client.Do(req) if err != nil { @@ -79,7 +79,7 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string, return data, ctype, resp.Header.Get("Content-Length"), nil } -func applyRemoteImageHeaders(req *http.Request, host string) { +func applyRemoteImageHeaders(req *http.Request, host, raw string) { req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36") req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7") @@ -88,7 +88,7 @@ func applyRemoteImageHeaders(req *http.Request, host string) { if cookie := remoteImageCookie(host); cookie != "" { req.Header.Set("Cookie", cookie) } - if referer := remoteImageReferer(host); referer != "" { + if referer := remoteImageReferer(host, raw); referer != "" { req.Header.Set("Referer", referer) } } @@ -105,7 +105,7 @@ func remoteImageCookie(host string) string { } } -func remoteImageReferer(host string) string { +func remoteImageReferer(host, raw string) string { h := strings.ToLower(strings.TrimSpace(host)) switch { case strings.Contains(h, "doubanio.com"): @@ -125,7 +125,11 @@ func remoteImageReferer(host string) string { case strings.Contains(h, "fc2.com"): return "https://adult.contents.fc2.com/" case h != "": - return "https://" + h + "/" + scheme := "https" + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(raw)), "http://") { + scheme = "http" + } + return scheme + "://" + h + "/" default: return "" } @@ -158,9 +162,9 @@ func fetchRemoteImageWithCurl(ctx context.Context, raw, host string) ([]byte, st "--header", "Cache-Control: no-cache", "--header", "Pragma: no-cache", } - if referer := remoteImageReferer(host); referer != "" { - args = append(args, "--referer", referer) - } + if referer := remoteImageReferer(host, raw); referer != "" { + args = append(args, "--referer", referer) + } if cookie := remoteImageCookie(host); cookie != "" { args = append(args, "--cookie", cookie) } diff --git a/internal/service/image_proxy_remote_test.go b/internal/service/image_proxy_remote_test.go index dd0a8e7..1374f5a 100644 --- a/internal/service/image_proxy_remote_test.go +++ b/internal/service/image_proxy_remote_test.go @@ -317,9 +317,14 @@ func TestRemoteImageRefererForAdultHosts(t *testing.T) { {"example.com", "https://example.com/"}, {"", ""}, } - for _, tt := range tests { - if got := remoteImageReferer(tt.host); got != tt.want { - t.Errorf("remoteImageReferer(%q) = %q, want %q", tt.host, got, tt.want) + for _, tt := range tests { + if got := remoteImageReferer(tt.host, "https://"+tt.host+"/img.jpg"); got != tt.want { + t.Errorf("remoteImageReferer(%q) = %q, want %q", tt.host, got, tt.want) + } + } + + // Also verify HTTP protocol preservation for generic hosts + if got := remoteImageReferer("192.168.1.100", "http://192.168.1.100:8096/image"); got != "http://192.168.1.100/" { + t.Errorf("remoteImageReferer for HTTP host = %q, want http://192.168.1.100/", got) } } -} diff --git a/internal/service/image_proxy_test.go b/internal/service/image_proxy_test.go index cb723d7..960f3cf 100644 --- a/internal/service/image_proxy_test.go +++ b/internal/service/image_proxy_test.go @@ -117,9 +117,32 @@ func TestIsPrivateHost(t *testing.T) { // Hostnames must NOT be blocked even though GFW DNS poisoning may resolve // them to private/loopback IPs — blocking them broke legitimate posters. allowed := []string{"image.tmdb.org", "lain.bgm.tv", "example.com", "8.8.8.8"} - for _, h := range allowed { - if isPrivateHost(h) { - t.Errorf("isPrivateHost(%q) = true, want false", h) + for _, h := range allowed { + if isPrivateHost(h) { + t.Errorf("isPrivateHost(%q) = true, want false", h) + } } } + +func TestImageProxyAllowedRemoteHostBypassesPrivateCheck(t *testing.T) { + proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) + + rawURL := "http://192.168.1.100:8096/emby/Items/123/Images/Primary" + // Before setting allowed remote hosts, private host is rejected by validateURL + if _, err := proxy.validateURL(rawURL); err == nil { + t.Fatal("expected validateURL to reject private IP before whitelist") + } + + // After configuring whitelist with the Emby host + proxy.SetAllowedRemoteHostsProvider(func() []string { + return []string{"192.168.1.100:8096"} + }) + + u, err := proxy.validateURL(rawURL) + if err != nil { + t.Fatalf("expected validateURL to allow whitelisted host, got: %v", err) + } + if u.Hostname() != "192.168.1.100" { + t.Fatalf("hostname = %s, want 192.168.1.100", u.Hostname()) + } } diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 37248a7..f953d63 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -177,6 +177,11 @@ func (b *serviceContainerBuilder) initIdentityServices() { func (b *serviceContainerBuilder) initImageProxy() { b.c.ImageProxy = NewImageProxy(b.cfg, b.log) b.c.ImageProxy.SetLibraryRootsProvider(b.libraryRoots) + if b.c.EmbyRemote != nil { + b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string { + return b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background()) + }) + } b.c.Scan.SetImageProxy(b.c.ImageProxy) b.c.Scraper.SetImageProxy(b.c.ImageProxy) } diff --git a/web/src/pages/AdminLibraryTable.tsx b/web/src/pages/AdminLibraryTable.tsx index a9e753f..83eb35c 100644 --- a/web/src/pages/AdminLibraryTable.tsx +++ b/web/src/pages/AdminLibraryTable.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type DragEvent, type MouseEvent, type Reac import { createPortal } from 'react-dom' import { Folder, GripVertical, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react' +import { imageURL } from '../api/client' import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog' import type { Library, LibraryRoot } from '../types' import type { RootDraft } from './adminLibraryPanelModel' @@ -169,7 +170,19 @@ function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver,
- {library.cover_url && } + {library.cover_url && ( + { + e.currentTarget.style.visibility = 'hidden' + }} + /> + )} {library.name}