diff --git a/api/video/poster.py b/api/video/poster.py index 02e8a3cc..90f14f97 100644 --- a/api/video/poster.py +++ b/api/video/poster.py @@ -117,12 +117,19 @@ def register_routes(bp): @bp.route("/img", methods=["GET"]) def video_img_proxy(): - """Same-origin proxy for TMDB images (image.tmdb.org ONLY — SSRF-safe). - Lets the detail/person pages canvas-sample a poster/portrait for the - per-title accent colour, which a direct cross-origin image taints.""" + """Same-origin image proxy (SSRF-safe allowlist). TMDB for accent-sampling, + and YouTube CDN (avatars/banners/thumbnails) so channel art loads reliably + regardless of hotlink/CORS policy.""" from flask import request + from urllib.parse import urlparse url = request.args.get("u", "") - if not url.startswith("https://image.tmdb.org/"): + if not url.startswith("https://"): + abort(404) + host = (urlparse(url).hostname or "").lower() + # image.tmdb.org + any YouTube image host (yt3/yt4.ggpht, googleusercontent, *.ytimg) + ok = host == "image.tmdb.org" or any( + host == s or host.endswith("." + s) for s in ("ytimg.com", "ggpht.com", "googleusercontent.com")) + if not ok: abort(404) try: import requests diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 3b274aa3..0ac355b3 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -667,3 +667,20 @@ def test_youtube_video_detail_404(tmp_path, monkeypatch): import core.video.youtube as ytmod monkeypatch.setattr(ytmod, "video_detail", lambda vid: None) assert client.get("/api/video/youtube/video/nope").status_code == 404 + + +def test_img_proxy_allows_youtube_cdn_only(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path) + import requests + + class FakeResp: + status_code = 200 + headers = {"Content-Type": "image/jpeg"} + def iter_content(self, n): yield b"x" + monkeypatch.setattr(requests, "get", lambda *a, **k: FakeResp()) + assert client.get("/api/video/img?u=https://yt3.googleusercontent.com/abc=s900").status_code == 200 + assert client.get("/api/video/img?u=https://i.ytimg.com/vi/x/hq.jpg").status_code == 200 + assert client.get("/api/video/img?u=https://image.tmdb.org/t/p/w500/x.jpg").status_code == 200 + # still SSRF-safe: arbitrary hosts rejected + assert client.get("/api/video/img?u=https://evil.example.com/x.jpg").status_code == 404 + assert client.get("/api/video/img?u=http://yt3.googleusercontent.com/x").status_code == 404 # http diff --git a/webui/static/video/video-channel.js b/webui/static/video/video-channel.js index 9fe6d686..db04280e 100644 --- a/webui/static/video/video-channel.js +++ b/webui/static/video/video-channel.js @@ -24,7 +24,7 @@ function videoCard(v) { var dur = YT().fmtDuration(v.duration_seconds); var thumb = v.thumbnail_url - ? '' : ''; var bits = []; @@ -52,12 +52,12 @@ (ch.videos || []).forEach(function (v) { state.videos[v.youtube_id] = v; }); var banner = $('[data-vc-banner]'); - if (banner) banner.style.backgroundImage = ch.banner_url ? "url('" + ch.banner_url + "')" : ''; + if (banner) banner.style.backgroundImage = ch.banner_url ? "url('" + YT().img(ch.banner_url) + "')" : ''; var page = $('[data-video-channel]'); if (page) page.setAttribute('data-has-banner', ch.banner_url ? '1' : '0'); var av = $('[data-vc-avatar]'), avph = $('[data-vc-avatar-ph]'); if (av) { - if (ch.avatar_url) { av.src = ch.avatar_url; show(av, true); if (avph) avph.hidden = true; } + if (ch.avatar_url) { av.src = YT().img(ch.avatar_url); show(av, true); if (avph) avph.hidden = true; } else { show(av, false); if (avph) avph.hidden = false; } } var name = $('[data-vc-name]'); if (name) name.textContent = ch.title || 'Channel'; diff --git a/webui/static/video/video-wishlist.js b/webui/static/video/video-wishlist.js index 89efad89..238029ae 100644 --- a/webui/static/video/video-wishlist.js +++ b/webui/static/video/video-wishlist.js @@ -75,6 +75,8 @@ // 'youtube') opens the in-app channel page. YEAR is the "season", video the // "episode" — the data is already shaped that way, so the same render runs. var yt = sh.source === 'youtube'; + // YouTube art goes through our image proxy (reliable load); tmdb passes through. + var pimg = function (u) { return (yt && window.VideoYoutube) ? VideoYoutube.img(u) : u; }; var src = sh.library_id != null ? 'library' : 'tmdb'; var openId = sh.library_id != null ? sh.library_id : sh.tmdb_id; var openAttrs = yt @@ -86,7 +88,7 @@ // fall back to the newest video's thumbnail so the orb is never blank. var poster = sh.poster_url || (yt && (sh.seasons || [])[0] ? sh.seasons[0].poster_url : null); var img = poster - ? '' : '
' + esc(initials(sh.title)) + '
'; // Episodes are shown grouped under a clickable season header (header → @@ -95,7 +97,7 @@ var seasons = (sh.seasons || []).map(function (se) { var n = se.episodes.length; var posterUrl = se.poster_url || sh.poster_url || null; - var thumb = posterUrl ? '' : '📺'; + var thumb = posterUrl ? '' : '📺'; var cards = (se.episodes || []).map(function (e) { return epCard(sh, se, e); }).join(''); var sName = yt ? (se.season_number ? se.season_number : 'Undated') : ('Season ' + se.season_number); var sRm = yt @@ -259,13 +261,14 @@ // the "View show" button in the info bar is what navigates. function epCard(sh, se, e) { var yt = sh.source === 'youtube'; + var pimg = function (u) { return (yt && window.VideoYoutube) ? VideoYoutube.img(u) : u; }; var t = e.title || (yt ? 'Untitled' : ('Episode ' + e.episode_number)); var st = STATUS[e.status] ? e.status : 'wanted'; var date = fmtDate(e.air_date); // TMDB shows the SxEx label; a YouTube video shows just its upload date. var metaTxt = yt ? (date || 'Video') : ('S' + se.season_number + '·E' + e.episode_number + (date ? ' · ' + esc(date) : '')); var thumb = e.still_url - ? '' : ''; var rm = yt diff --git a/webui/static/video/video-youtube.js b/webui/static/video/video-youtube.js index e2e772a7..19dfabef 100644 --- a/webui/static/video/video-youtube.js +++ b/webui/static/video/video-youtube.js @@ -33,9 +33,19 @@ return (w.slice(0, 2).map(function (x) { return x[0]; }).join('') || '▶').toUpperCase(); } + // Route YouTube CDN images through our same-origin proxy so hotlink/CORS + // policy can't blank them out. Non-YouTube / already-proxied urls pass through. + function img(url) { + if (!url) return url; + if (/^https:\/\/([\w-]+\.)?(ytimg\.com|ggpht\.com|googleusercontent\.com)\//i.test(url)) + return '/api/video/img?u=' + encodeURIComponent(url); + return url; + } + function avatar(ch, cls) { - if (ch && ch.poster_url || (ch && ch.avatar_url)) { - return ''; } return '' + esc(initials(ch && ch.title)) + ''; @@ -62,7 +72,7 @@ function searchCard(ch, following) { var strip = (ch.videos || []).slice(0, 6).map(function (v) { return v.thumbnail_url - ? '' : ''; }).join(''); @@ -125,7 +135,7 @@ } window.VideoYoutube = { - esc: esc, isChannelRef: isChannelRef, fmtDate: fmtDate, avatar: avatar, + esc: esc, isChannelRef: isChannelRef, fmtDate: fmtDate, avatar: avatar, img: img, fmtDuration: fmtDuration, compactCount: compactCount, videoCard: videoCard, searchCard: searchCard, followBtn: followBtn, follow: follow, unfollow: unfollow, removeWish: removeWish, addVideos: addVideos, resolve: resolve,