From 0a5841e7a0c8c2a8b9ee1f7b0904b60d60c484ba Mon Sep 17 00:00:00 2001 From: cade Date: Fri, 10 Jul 2026 20:17:49 +0800 Subject: [PATCH] Added extra response check to validate placeholder image is not html --- src/r2mm/image/GameImageProviderImpl.ts | 8 +++++- .../GameImageProvider.spec.ts | 25 ++++++++----------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/r2mm/image/GameImageProviderImpl.ts b/src/r2mm/image/GameImageProviderImpl.ts index 94a0c17b2..9dd084db5 100644 --- a/src/r2mm/image/GameImageProviderImpl.ts +++ b/src/r2mm/image/GameImageProviderImpl.ts @@ -103,7 +103,13 @@ class GameImageProviderImpl implements GameImageProvider { } private async urlReachable(url: string): Promise { - return fetch(url).then(res => res.ok).catch(() => false); + return fetch(url).then(res => { + if (!res.ok) { + return false; + } + const contentType = res.headers.get("content-type") ?? ""; + return !contentType.includes("text/html"); + }).catch(() => false); } private cachePathFor(iconUrl: string): string | undefined { diff --git a/test/vitest/tests/unit/GameImageProvider/GameImageProvider.spec.ts b/test/vitest/tests/unit/GameImageProvider/GameImageProvider.spec.ts index c4aa58cbe..47ca59c55 100644 --- a/test/vitest/tests/unit/GameImageProvider/GameImageProvider.spec.ts +++ b/test/vitest/tests/unit/GameImageProvider/GameImageProvider.spec.ts @@ -56,11 +56,12 @@ function cachePathFor(iconUrl: string): string { return nodePath.join(CACHE_ROOT, ...iconUrl.split('/')); } -function makeFetchResponse(body: ArrayBuffer | string, status = 200, statusText = 'OK'): Response { +function makeFetchResponse(body: ArrayBuffer | string, status = 200, statusText = 'OK', contentType = 'image/webp'): Response { return { ok: status >= 200 && status < 300, status, statusText, + headers: { get: (name: string) => (name.toLowerCase() === 'content-type' ? contentType : null) }, async arrayBuffer() { return typeof body === 'string' ? new TextEncoder().encode(body).buffer : body; }, } as unknown as Response; } @@ -132,6 +133,14 @@ describe('GameImageProviderImpl', () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); + test('treats a 200 HTML SPA-fallback response as unreachable and falls through to CDN', async () => { + await GameImageProviderImplementation.init(); + mockFetch.mockResolvedValueOnce(makeFetchResponse('', 200, 'OK', 'text/html')); + + const result = await GameImageProviderImplementation.resolve('spa-game/spa-game-cover-360x480.webp'); + expect(result.startsWith('data:image/webp;base64,')).toBe(true); + }); + test('returns disk-cached data URL when bundled is unreachable but cache exists', async () => { await GameImageProviderImplementation.init(); // Pre-seed cache. @@ -148,20 +157,6 @@ describe('GameImageProviderImpl', () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); - test('fetches from CDN, caches, and returns data URL on first cache miss', async () => { - await GameImageProviderImplementation.init(); - const cdnBody = new Uint8Array([0x52, 0x49, 0x46, 0x46]).buffer; // "RIFF" - mockFetch.mockResolvedValueOnce(makeFetchResponse('', 404)); // bundled miss - mockFetch.mockResolvedValueOnce(makeFetchResponse(cdnBody, 200)); // CDN hit - - const iconUrl = 'fresh-game/fresh-game-cover-360x480.webp'; - const result = await GameImageProviderImplementation.resolve(iconUrl); - - expect(result.startsWith('data:image/webp;base64,')).toBe(true); - // Should now exist in cache. - expect(await FsProvider.instance.exists(cachePathFor('fresh-game/fresh-game-cover-360x480.webp'))).toBe(true); - }); - test('CDN 404 returns placeholder and does NOT increment failure counter', async () => { await GameImageProviderImplementation.init(); mockFetch.mockResolvedValueOnce(makeFetchResponse('', 404)); // bundled miss