mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-07 22:06:38 +08:00
fix(auth/images): long-lived Emby token, unthrottle refresh, serve library posters
Three regressions reported on third-party clients and the web UI: - Third-party clients (Emby/Jellyfin) dropped login / could not play / could not refresh the library, roughly hourly. The Emby AuthenticateByName response returned the 60-minute access token, but Emby clients have no refresh mechanism and reuse the AccessToken until logout. Issue a long-lived (30d) token for the Emby compat layer via AuthService.IssueEmbyToken so device sessions persist. - Web could be thrown back to login under load: /auth/refresh was inside the IP rate-limited /auth group, so multiple users/tabs behind one reverse-proxy/NAT IP exhausted the budget and refresh failed -> logout. Only login/register are rate-limited now (raised to 30/min for shared IPs); refresh is excluded (already protected by a one-time refresh token). - Posters/images stopped displaying on the web home and other pages (refresh did not help). The SSRF/path hardening (a) blocked the image proxy whenever a hostname *resolved* to a private IP, which happens under GFW DNS poisoning of public CDNs like image.tmdb.org, and (b) restricted local image reads to data/cache/movies/tv/anime dirs only, dropping sidecar posters stored under arbitrary per-library roots to a placeholder. isPrivateHost now only blocks literal private/loopback IPs (real SSRF vectors) and ImageProxy also allows reads under configured library roots. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -207,8 +207,16 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
userPayload, _ := svc.Emby.FindUser(c.Request.Context(), resp.User.ID)
|
userPayload, _ := svc.Emby.FindUser(c.Request.Context(), resp.User.ID)
|
||||||
|
// Emby/Jellyfin 客户端没有 refresh token 机制:它们把这里返回的
|
||||||
|
// AccessToken 长期保存并反复使用。若返回 60 分钟的普通 access
|
||||||
|
// token,客户端每小时就会掉登录、无法播放、媒体库无法刷新。因此
|
||||||
|
// 签发长期令牌(IssueEmbyToken)匹配 Emby 持久化令牌语义。
|
||||||
|
accessToken := resp.Tokens.AccessToken
|
||||||
|
if longLived, err := svc.Auth.IssueEmbyToken(resp.User); err == nil && longLived != "" {
|
||||||
|
accessToken = longLived
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"AccessToken": resp.Tokens.AccessToken,
|
"AccessToken": accessToken,
|
||||||
"ServerId": "mediastation-go-001",
|
"ServerId": "mediastation-go-001",
|
||||||
"User": userPayload,
|
"User": userPayload,
|
||||||
"SessionInfo": gin.H{
|
"SessionInfo": gin.H{
|
||||||
@@ -729,7 +737,9 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
|
|||||||
grp.HEAD(path, embyPingHandler(svc))
|
grp.HEAD(path, embyPingHandler(svc))
|
||||||
grp.POST(path, embyPingHandler(svc))
|
grp.POST(path, embyPingHandler(svc))
|
||||||
}
|
}
|
||||||
embyLoginLimiter := middleware.NewRateLimiter(10, 1*time.Minute)
|
// 30/min per IP: many Emby clients sit behind a single NAT/reverse-proxy
|
||||||
|
// IP, so a low limit would throttle legitimate logins into 429s.
|
||||||
|
embyLoginLimiter := middleware.NewRateLimiter(30, 1*time.Minute)
|
||||||
for _, path := range []string{"/Users/AuthenticateByName", "/users/authenticatebyname"} {
|
for _, path := range []string{"/Users/AuthenticateByName", "/users/authenticatebyname"} {
|
||||||
grp.POST(path, middleware.RateLimit(embyLoginLimiter), embyAuthByNameHandler(svc))
|
grp.POST(path, middleware.RateLimit(embyLoginLimiter), embyAuthByNameHandler(svc))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,18 +25,24 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
|||||||
// Telegram Bot webhook — called by Telegram servers, no auth.
|
// Telegram Bot webhook — called by Telegram servers, no auth.
|
||||||
api.POST("/telegram/webhook", telegramWebhookHandler(svc))
|
api.POST("/telegram/webhook", telegramWebhookHandler(svc))
|
||||||
|
|
||||||
// Rate limiter for auth endpoints: 10 attempts per minute per IP.
|
// Rate limiter for credential endpoints (login/register): brute-force
|
||||||
authLimiter := middleware.NewRateLimiter(10, 1*time.Minute)
|
// protection. 30/min per IP tolerates many users behind a single NAT
|
||||||
|
// or reverse-proxy IP while still throttling password guessing.
|
||||||
|
authLimiter := middleware.NewRateLimiter(30, 1*time.Minute)
|
||||||
|
|
||||||
// Public auth.
|
// Public auth.
|
||||||
auth := api.Group("/auth")
|
auth := api.Group("/auth")
|
||||||
auth.Use(middleware.RateLimit(authLimiter))
|
|
||||||
{
|
{
|
||||||
auth.POST("/login", loginHandler(svc))
|
auth.POST("/login", middleware.RateLimit(authLimiter), loginHandler(svc))
|
||||||
auth.POST("/register", registerHandler(svc))
|
auth.POST("/register", middleware.RateLimit(authLimiter), registerHandler(svc))
|
||||||
// /auth/refresh 用 RefreshHandler.RefreshToken:它从 body 读
|
// /auth/refresh 用 RefreshHandler.RefreshToken:它从 body 读
|
||||||
// refresh_token 并签发新 access/refresh 对。旧的 refreshHandler
|
// refresh_token 并签发新 access/refresh 对。旧的 refreshHandler
|
||||||
// 依赖 AuthRequired 中间件,永远 401,因此弃用。
|
// 依赖 AuthRequired 中间件,永远 401,因此弃用。
|
||||||
|
//
|
||||||
|
// 刷新端点【不】做 IP 限流:刷新本身就是防止掉登录的机制,且已
|
||||||
|
// 由一次性轮换的 refresh token 强校验。若按 IP 限流,多个用户/
|
||||||
|
// 标签页共用一个反代 IP 时会把正常刷新打成 429,反而导致频繁
|
||||||
|
// 掉登录。
|
||||||
refreshHd := NewRefreshHandler(svc, log)
|
refreshHd := NewRefreshHandler(svc, log)
|
||||||
auth.POST("/refresh", refreshHd.RefreshToken)
|
auth.POST("/refresh", refreshHd.RefreshToken)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,33 @@ func (s *AuthService) IssueToken(u *model.User) (string, error) {
|
|||||||
return t.SignedString([]byte(s.cfg.Secrets.JWTSecret))
|
return t.SignedString([]byte(s.cfg.Secrets.JWTSecret))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EmbyTokenDuration 是第三方 Emby/Jellyfin 客户端访问令牌的有效期。
|
||||||
|
// Emby 协议没有 refresh token 机制——客户端登录一次后把 AccessToken
|
||||||
|
// 长期保存并反复使用,直到用户主动登出。若给它们签发 60 分钟的普通
|
||||||
|
// access token,客户端每小时就会掉登录、无法播放、媒体库无法刷新。
|
||||||
|
// 因此为这些设备签发长期令牌(与 refresh token 一致的 30 天),匹配
|
||||||
|
// Emby 持久化令牌的语义。
|
||||||
|
const EmbyTokenDuration = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// IssueEmbyToken 为第三方客户端(Emby/Jellyfin 兼容层)签发一个长期
|
||||||
|
// JWT。它与普通 access token 使用相同的密钥与 Claims,因此沿用现有的
|
||||||
|
// EmbyAuthRequired 校验逻辑,只是有效期更长。
|
||||||
|
func (s *AuthService) IssueEmbyToken(u *model.User) (string, error) {
|
||||||
|
claims := Claims{
|
||||||
|
UserID: u.ID,
|
||||||
|
Role: u.Role,
|
||||||
|
Tier: u.Tier,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(EmbyTokenDuration)),
|
||||||
|
Issuer: "mediastationgo",
|
||||||
|
Subject: u.ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return t.SignedString([]byte(s.cfg.Secrets.JWTSecret))
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshTokens 使用刷新令牌获取新的令牌对。
|
// RefreshTokens 使用刷新令牌获取新的令牌对。
|
||||||
func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||||
return s.tokenSvc.Refresh(ctx, refreshToken)
|
return s.tokenSvc.Refresh(ctx, refreshToken)
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
@@ -190,3 +192,35 @@ func TestDefaultAdminCannotBeDemoted(t *testing.T) {
|
|||||||
t.Fatal("expected default admin demotion to be rejected")
|
t.Fatal("expected default admin demotion to be rejected")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIssueEmbyTokenIsLongLived verifies the Emby/Jellyfin compatibility token
|
||||||
|
// outlives the 60-minute access token (Emby clients have no refresh mechanism,
|
||||||
|
// so a short token caused them to drop login / fail playback hourly), parses
|
||||||
|
// with the JWT secret, and carries the user's identity/role/tier.
|
||||||
|
func TestIssueEmbyTokenIsLongLived(t *testing.T) {
|
||||||
|
_, auth, _, _ := newAuthTestServices(t)
|
||||||
|
u := &model.User{Base: model.Base{ID: "u-emby"}, Username: "emby", Role: "user", Tier: "plus"}
|
||||||
|
|
||||||
|
tok, err := auth.IssueEmbyToken(u)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueEmbyToken: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := &Claims{}
|
||||||
|
parsed, err := jwt.ParseWithClaims(tok, claims, func(*jwt.Token) (interface{}, error) {
|
||||||
|
return []byte("test-secret"), nil
|
||||||
|
})
|
||||||
|
if err != nil || !parsed.Valid {
|
||||||
|
t.Fatalf("token did not parse/validate: %v", err)
|
||||||
|
}
|
||||||
|
if claims.UserID != "u-emby" || claims.Role != "user" || claims.Tier != "plus" {
|
||||||
|
t.Fatalf("unexpected claims: %+v", claims)
|
||||||
|
}
|
||||||
|
ttl := time.Until(claims.ExpiresAt.Time)
|
||||||
|
if ttl <= AccessTokenDuration {
|
||||||
|
t.Fatalf("emby token ttl %v not longer than access token ttl %v", ttl, AccessTokenDuration)
|
||||||
|
}
|
||||||
|
if ttl < EmbyTokenDuration-time.Hour {
|
||||||
|
t.Fatalf("emby token ttl %v shorter than expected ~%v", ttl, EmbyTokenDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ type ImageProxy struct {
|
|||||||
client *http.Client
|
client *http.Client
|
||||||
cacheDir string
|
cacheDir string
|
||||||
mu sync.Mutex
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewImageProxy is the constructor.
|
// NewImageProxy is the constructor.
|
||||||
@@ -89,6 +98,31 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
// validateURL parses raw and ensures the scheme is http/https and the
|
// validateURL parses raw and ensures the scheme is http/https and the
|
||||||
// target host is not a private/loopback/link-local address (SSRF guard).
|
// target host is not a private/loopback/link-local address (SSRF guard).
|
||||||
func (p *ImageProxy) validateURL(raw string) (*url.URL, error) {
|
func (p *ImageProxy) validateURL(raw string) (*url.URL, error) {
|
||||||
@@ -109,9 +143,16 @@ func (p *ImageProxy) validateURL(raw string) (*url.URL, error) {
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isPrivateHost returns true if host resolves to a loopback, private, or
|
// isPrivateHost returns true only when host is a *literal* loopback, private,
|
||||||
// link-local address. This blocks SSRF attacks that try to reach internal
|
// link-local or unspecified IP address. This blocks the obvious SSRF vectors
|
||||||
// services (e.g. cloud metadata at 169.254.169.254).
|
// (e.g. http://127.0.0.1/… or the cloud metadata IP 169.254.169.254) while
|
||||||
|
// NOT blocking hostnames.
|
||||||
|
//
|
||||||
|
// We deliberately do not resolve hostnames here: under GFW DNS poisoning,
|
||||||
|
// public image CDNs such as image.tmdb.org are frequently resolved to
|
||||||
|
// loopback/private/bogus IPs. Blocking on resolved addresses would therefore
|
||||||
|
// wrongly drop legitimate posters for exactly the users this proxy exists to
|
||||||
|
// serve, which is what caused posters to stop displaying.
|
||||||
func isPrivateHost(host string) bool {
|
func isPrivateHost(host string) bool {
|
||||||
if host == "" {
|
if host == "" {
|
||||||
return true
|
return true
|
||||||
@@ -120,24 +161,19 @@ func isPrivateHost(host string) bool {
|
|||||||
if ip != nil {
|
if ip != nil {
|
||||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified()
|
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified()
|
||||||
}
|
}
|
||||||
addrs, err := net.LookupHost(host)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, addr := range addrs {
|
|
||||||
resolved := net.ParseIP(addr)
|
|
||||||
if resolved != nil && (resolved.IsLoopback() || resolved.IsPrivate() || resolved.IsLinkLocalUnicast() || resolved.IsLinkLocalMulticast() || resolved.IsUnspecified()) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAllowedLocalPath restricts local file reads to the data directory and
|
// isAllowedLocalPath restricts local file reads to known-safe roots to
|
||||||
// cache directory to prevent arbitrary file read via path traversal.
|
// prevent arbitrary file reads via path traversal. Allowed roots are the
|
||||||
|
// data dir, cache dir, the configured movies/tv/anime dirs, and — crucially —
|
||||||
|
// every configured media library root, because sidecar posters/artwork are
|
||||||
|
// stored alongside media under those (arbitrary, user-defined) paths.
|
||||||
func (p *ImageProxy) isAllowedLocalPath(abs string) bool {
|
func (p *ImageProxy) isAllowedLocalPath(abs string) bool {
|
||||||
for _, root := range []string{p.cfg.App.DataDir, p.cfg.Cache.CacheDir, p.cfg.Media.MoviesDir, p.cfg.Media.TVDir, p.cfg.Media.AnimeDir} {
|
roots := []string{p.cfg.App.DataDir, p.cfg.Cache.CacheDir, p.cfg.Media.MoviesDir, p.cfg.Media.TVDir, p.cfg.Media.AnimeDir}
|
||||||
if root == "" {
|
roots = append(roots, p.libraryRoots()...)
|
||||||
|
for _, root := range roots {
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rootAbs, err := filepath.Abs(root)
|
rootAbs, err := filepath.Abs(root)
|
||||||
|
|||||||
@@ -36,3 +36,61 @@ func TestImageProxyServesLocalImagePath(t *testing.T) {
|
|||||||
t.Fatalf("body length = %d, want %d", rec.Body.Len(), len(transparent1x1PNG))
|
t.Fatalf("body length = %d, want %d", rec.Body.Len(), len(transparent1x1PNG))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestImageProxyServesPosterUnderLibraryRoot verifies that sidecar posters
|
||||||
|
// stored under an arbitrary media library root (not the configured
|
||||||
|
// data/cache/movies dirs) are served rather than dropped to the placeholder.
|
||||||
|
// This is the regression that made web/Emby posters disappear.
|
||||||
|
func TestImageProxyServesPosterUnderLibraryRoot(t *testing.T) {
|
||||||
|
libDir := t.TempDir()
|
||||||
|
posterPath := filepath.Join(libDir, "Inception (2010)", "poster.png")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(posterPath), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
realPoster := []byte("THIS-IS-A-REAL-POSTER-NOT-THE-PLACEHOLDER")
|
||||||
|
if err := os.WriteFile(posterPath, realPoster, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||||
|
|
||||||
|
// Without a library-roots provider the poster lives outside every allowed
|
||||||
|
// root, so it must fall back to the transparent placeholder.
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), posterPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec.Body.Len() != len(transparent1x1PNG) {
|
||||||
|
t.Fatalf("expected placeholder before provider set, got %d bytes", rec.Body.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the library root is known, the real poster bytes are served.
|
||||||
|
proxy.SetLibraryRootsProvider(func() []string { return []string{libDir} })
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), posterPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Body.Bytes(); string(got) != string(realPoster) {
|
||||||
|
t.Fatalf("served %q, want real poster bytes", string(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPrivateHost(t *testing.T) {
|
||||||
|
blocked := []string{"127.0.0.1", "10.0.0.5", "192.168.1.10", "169.254.169.254", "0.0.0.0", "::1", ""}
|
||||||
|
for _, h := range blocked {
|
||||||
|
if !isPrivateHost(h) {
|
||||||
|
t.Errorf("isPrivateHost(%q) = false, want true (literal private/loopback IP)", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -131,6 +132,24 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
downloads := NewDownloadService(log, repos, hub, organizer, siteSvc)
|
downloads := NewDownloadService(log, repos, hub, organizer, siteSvc)
|
||||||
subscription := NewSubscriptionService(cfg, log, repos, downloads, siteSvc, hub)
|
subscription := NewSubscriptionService(cfg, log, repos, downloads, siteSvc, hub)
|
||||||
|
|
||||||
|
// 让图片代理把媒体库根目录视为可读的本地图片位置:海报/封面等
|
||||||
|
// sidecar 资源就存放在这些(用户自定义、任意)目录下,否则会被
|
||||||
|
// 路径白名单挡掉、退化成占位图导致前端图片不显示。
|
||||||
|
imageProxy := NewImageProxy(cfg, log)
|
||||||
|
imageProxy.SetLibraryRootsProvider(func() []string {
|
||||||
|
libs, err := repos.Library.List(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
roots := make([]string, 0, len(libs))
|
||||||
|
for _, l := range libs {
|
||||||
|
if strings.TrimSpace(l.Path) != "" {
|
||||||
|
roots = append(roots, l.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots
|
||||||
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
return &Container{
|
return &Container{
|
||||||
@@ -152,7 +171,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
Scraper: scraper,
|
Scraper: scraper,
|
||||||
Discover: discover,
|
Discover: discover,
|
||||||
Playback: NewPlaybackService(log, repos),
|
Playback: NewPlaybackService(log, repos),
|
||||||
ImageProxy: NewImageProxy(cfg, log),
|
ImageProxy: imageProxy,
|
||||||
Watcher: watcher,
|
Watcher: watcher,
|
||||||
Downloads: downloads,
|
Downloads: downloads,
|
||||||
Subscription: subscription,
|
Subscription: subscription,
|
||||||
|
|||||||
Reference in New Issue
Block a user