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:
parent
b3fd6037bb
commit
9348166bb5
5 changed files with 51 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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="wl-orb-initials">' + 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue