From 8e1004fe6fbf49cae05eec6689eca2db9cbbea98 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 26 Sep 2026 21:45:46 +0200 Subject: [PATCH] fix(channel): load the banner without a referrer Google's image CDN refuses a banner request that carries a Referer from our origin - every time, not only under load (measured: 6/6 refused with it, 8/8 served without). The banner now sends none, as ui/Avatar does for the same CDN; the comments that blamed a transient burst throttle are corrected. --- .../ChannelPage.banner.dom.test.tsx | 89 +++++++++++++++++++ frontend/src/components/ChannelPage.tsx | 11 +-- frontend/src/components/ui/Avatar.tsx | 9 +- 3 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/ChannelPage.banner.dom.test.tsx diff --git a/frontend/src/components/ChannelPage.banner.dom.test.tsx b/frontend/src/components/ChannelPage.banner.dom.test.tsx new file mode 100644 index 00000000..5d886379 --- /dev/null +++ b/frontend/src/components/ChannelPage.banner.dom.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +// +// The channel banner is served from Google's image CDN (yt3.googleusercontent.com), which refuses a +// request that carries a Referer from our origin — every time, not only under load (measured +// 2026-09-26: 6/6 refused with the page's referrer or its bare origin, 8/8 served without one). So the +// banner must send none, as ui/Avatar does for the same CDN; with a referrer the banner never +// showed and the page fell back to the blank bar. +import type { ReactNode } from "react"; +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, cleanup, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +vi.mock("./navigationContext", () => ({ + useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }), +})); +vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); +vi.mock("./feedFiltersContext", () => ({ + useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }), +})); +vi.mock("./Feed", () => ({ default: () => null })); +vi.mock("./ChannelAbout", () => ({ default: () => null })); +// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for. +vi.mock("./ui/PageShell", async (importOriginal) => ({ + ...(await importOriginal()), + PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}, +})); + +import ChannelPage from "./ChannelPage"; +import { api, type ChannelDetail, type Me } from "../lib/api"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const CH = "UCqoAEDirJPjEUFcF2FklnBA"; + +describe("ChannelPage — the banner", () => { + it("is requested without a referrer, which Google's image CDN refuses", async () => { + vi.spyOn(api, "channelDetail").mockResolvedValue({ + id: CH, + title: "StarTalk", + banner_url: "https://yt3.googleusercontent.com/banner", + subscribed: true, + blocked: false, + hidden: false, + deep_requested: false, + backfill_done: true, + stored_videos: 10, + } as unknown as ChannelDetail); + vi.spyOn(api, "tags").mockResolvedValue([] as never); + const { container } = render( + + {}} + onShowInFeed={() => {}} + /> + , + ); + const banner = await waitFor(() => { + const img = container.querySelector('img[src^="https://yt3."]'); + expect(img).not.toBeNull(); + return img!; + }); + expect(banner.getAttribute("referrerpolicy")).toBe("no-referrer"); + }); +}); diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 0075a98a..8fc69a34 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -79,11 +79,11 @@ export default function ChannelPage({ // where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so // the toolbar lands under the tabs inside the fixed band rather than in the scroller. const [chromeSlot, setChromeSlot] = useState(null); - // The banner URL is valid, but googleusercontent intermittently throttles it when the channel - // page fires the banner + avatar + ~16 thumbnails in one burst — the request is dropped and the - // browser then NEGATIVE-CACHES the miss, so the banner stays broken even though a retry would - // succeed. So on error we retry a few times with a cache-buster (forcing a fresh request), and - // only fall back to the blank bar if it keeps failing (a genuinely dead URL — rare). + // The banner comes from Google's image CDN, which refuses any request carrying a Referer from our + // origin — every time, not only in a burst (measured 2026-09-26) — so the sends none + // (`referrerPolicy`, as ui/Avatar). A load can still drop, and the browser NEGATIVE-CACHES the miss, + // so on error we retry a few times with a cache-buster (forcing a fresh request), and only fall + // back to the blank bar if it keeps failing (a genuinely dead URL — rare). const MAX_BANNER_RETRIES = 3; const [bannerAttempt, setBannerAttempt] = useState(0); @@ -335,6 +335,7 @@ export default function ChannelPage({ // browser's negative cache (googleusercontent accepts an extra query param). key={bannerAttempt} src={`${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`} + referrerPolicy="no-referrer" alt="" className="w-full h-full object-cover object-center" onError={() => { diff --git a/frontend/src/components/ui/Avatar.tsx b/frontend/src/components/ui/Avatar.tsx index 7172df31..d81f7621 100644 --- a/frontend/src/components/ui/Avatar.tsx +++ b/frontend/src/components/ui/Avatar.tsx @@ -3,10 +3,11 @@ import { useState } from "react"; // Avatar image with a graceful fallback. // // Uses referrerPolicy="no-referrer" because Google's avatar CDNs -// (yt3.ggpht.com / lh3.googleusercontent.com) rate-limit bursts of -// referrer-bearing requests — when a feed page fires dozens of avatar loads at -// once, a random subset gets throttled (HTTP 429) and the browser shows its -// broken-image icon. Dropping the referrer makes those CDNs serve the images. +// (yt3.ggpht.com / lh3.googleusercontent.com) refuse referrer-bearing requests +// from our origin — yt3 refuses even a single load, not only a burst (measured +// 2026-09-26) — and the browser shows its broken-image icon. Dropping the +// referrer makes those CDNs serve the images. The channel banner, from the same +// CDN, does the same (ChannelPage). // If a load still fails we render a neutral placeholder (optionally an initial) // instead of the broken-image icon. export default function Avatar({