YouTube: proxy channel/video images so avatars + thumbnails always load

The channel avatar (and video thumbnails) are yt3.googleusercontent.com /
i.ytimg.com URLs; hotlink/CORS policy could blank them (failed <img> hides on
the watchlist, falls to initials on the wishlist orb) — which is why the poster
'vanished' on both pages.

Extend the /api/video/img proxy allowlist to YouTube CDN hosts (ytimg.com /
ggpht.com / googleusercontent.com, https-only, still SSRF-safe) and route all
YouTube art through it: VideoYoutube.img() helper used by the watchlist channel
cards, search chip, the wishlist nebula orb/season/still art, and the channel
detail avatar/banner/thumbnails. 2 img-proxy tests green; JS balanced.
This commit is contained in:
BoulderBadgeDad 2026-06-17 08:40:33 -07:00
parent b3fd6037bb
commit 9348166bb5
5 changed files with 51 additions and 14 deletions

View file

@ -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

View file

@ -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

View file

@ -24,7 +24,7 @@
function videoCard(v) {
var dur = YT().fmtDuration(v.duration_seconds);
var thumb = v.thumbnail_url
? '<img class="vc-vid-img" src="' + esc(v.thumbnail_url) + '" alt="" loading="lazy" ' +
? '<img class="vc-vid-img" src="' + esc(YT().img(v.thumbnail_url)) + '" alt="" loading="lazy" ' +
'onerror="this.parentNode.classList.add(\'vc-vid-thumb--none\')">'
: '';
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';

View file

@ -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
? '<img class="wl-orb-img" src="' + esc(poster) + '" alt="" ' +
? '<img class="wl-orb-img" src="' + esc(pimg(poster)) + '" alt="" ' +
'onerror="this.outerHTML=\'<div class=&quot;wl-orb-initials&quot;>' + esc(initials(sh.title)) + '</div>\'">'
: '<div class="wl-orb-initials">' + esc(initials(sh.title)) + '</div>';
// 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 ? '<img src="' + esc(posterUrl) + '" alt="">' : '<span class="vwsh-szn-ph">📺</span>';
var thumb = posterUrl ? '<img src="' + esc(pimg(posterUrl)) + '" alt="">' : '<span class="vwsh-szn-ph">📺</span>';
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
? '<span class="vwsh-epc-thumb"><img src="' + esc(e.still_url) + '" alt="" loading="lazy" ' +
? '<span class="vwsh-epc-thumb"><img src="' + esc(pimg(e.still_url)) + '" alt="" loading="lazy" ' +
'onerror="this.parentNode.classList.add(\'vwsh-epc-thumb--none\')"></span>'
: '<span class="vwsh-epc-thumb vwsh-epc-thumb--none"></span>';
var rm = yt

View file

@ -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 '<img class="' + cls + '" src="' + esc(ch.poster_url || ch.avatar_url) +
var url = ch && (ch.poster_url || ch.avatar_url);
if (url) {
return '<img class="' + cls + '" src="' + esc(img(url)) +
'" alt="" loading="lazy" onerror="this.style.display=\'none\'">';
}
return '<span class="' + cls + ' vyt-avatar--ph">' + esc(initials(ch && ch.title)) + '</span>';
@ -62,7 +72,7 @@
function searchCard(ch, following) {
var strip = (ch.videos || []).slice(0, 6).map(function (v) {
return v.thumbnail_url
? '<span class="vyt-strip-cell"><img src="' + esc(v.thumbnail_url) + '" alt="" loading="lazy" ' +
? '<span class="vyt-strip-cell"><img src="' + esc(img(v.thumbnail_url)) + '" alt="" loading="lazy" ' +
'onerror="this.parentNode.style.display=\'none\'"></span>'
: '';
}).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,