From ab48a5678edf06b8d15ef8d04562b0928d31cbd8 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 20 Sep 2026 23:36:55 -0700 Subject: [PATCH] fix(stats): restrict local requests and serve the dashboard over HTTP (#263) --- changes/stats-request-safety.md | 6 + docs-site/immersion-tracking.md | 22 +++ .../services/__tests__/stats-server.test.ts | 125 +++++++++++++++++- src/core/services/stats-server.ts | 7 +- .../services/stats-server/request-safety.ts | 42 ++++++ src/core/services/stats-window-runtime.ts | 13 +- src/core/services/stats-window.test.ts | 19 +-- src/core/services/stats-window.ts | 12 +- src/main.ts | 2 - stats/src/App.tsx | 3 +- .../components/vocabulary/WordDetailPanel.tsx | 3 +- stats/src/lib/api-client.test.ts | 37 +----- stats/src/lib/api-client.ts | 20 +-- stats/src/lib/asset-url.test.ts | 39 ------ stats/src/lib/asset-url.ts | 24 ---- stats/src/lib/media-library-grouping.test.tsx | 4 +- 16 files changed, 221 insertions(+), 157 deletions(-) create mode 100644 changes/stats-request-safety.md create mode 100644 src/core/services/stats-server/request-safety.ts delete mode 100644 stats/src/lib/asset-url.test.ts delete mode 100644 stats/src/lib/asset-url.ts diff --git a/changes/stats-request-safety.md b/changes/stats-request-safety.md new file mode 100644 index 00000000..4f3da8f1 --- /dev/null +++ b/changes/stats-request-safety.md @@ -0,0 +1,6 @@ +type: changed +breaking: true +area: stats + +- Reject requests from untrusted browser origins and hosts before stats data, media, or Anki operations run, and require JSON for mutation bodies. +- Load the in-app stats overlay from the local server so it uses the same origin protection as the browser dashboard. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index ca45d272..d81eb34e 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -31,6 +31,28 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_ The same immersion data powers the stats dashboard. +The browser dashboard and in-app stats overlay both load from the local HTTP server. +The server accepts loopback hosts only and rejects requests from other browser origins, +including opaque origins such as `file://`. API clients without a browser origin can +still use the local API. Mutation requests with a body must use `application/json`; +bodyless deletion and Anki browse requests remain supported. Requests rejected by the +host or origin checks receive `403`; mutation bodies without a JSON content type +receive `415`. + +Use the loopback dashboard URL directly. Reverse-proxied dashboards and Tailscale +Serve URLs are unsupported because their host or browser origin is not the local +server's origin. SSH stats synchronization is unchanged. + +Scripts sending a JSON body must include the content type. For example, this +requests a duplicate-line cleanup preview without changing the database. Replace +the port if you configured a different `stats.serverPort`: + +```bash +curl http://127.0.0.1:6969/api/stats/maintenance/duplicate-lines \ + -H 'Content-Type: application/json' \ + -d '{"dryRun":true}' +``` + - In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`). - Launcher command: run `subminer stats` to start the local stats server on demand (it also opens the dashboard in your browser when `stats.autoOpenBrowser` is enabled; the default is `false`). - Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon. diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 83f3162f..7f32ada2 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -444,6 +444,73 @@ async function withFakeAnkiConnect( } describe('stats server API routes', () => { + it('rejects untrusted mutation requests before merging anime', async () => { + let merges = 0; + const app = createStatsApp( + createMockTracker({ + mergeAnime: async () => { + merges += 1; + return { survivingAnimeId: 1, mergedAnimeIds: [2], movedVideos: 1 }; + }, + }), + ); + const rejectedHeaders: Record[] = [ + { Origin: 'https://attacker.example', 'Content-Type': 'text/plain' }, + { Origin: 'https://attacker.example', 'Content-Type': 'application/json' }, + { Origin: 'null', 'Content-Type': 'application/json' }, + { Origin: 'http://localhost:4321', 'Content-Type': 'application/json' }, + { Origin: 'http://localhost/', 'Content-Type': 'application/json' }, + { 'Sec-Fetch-Site': 'cross-site', 'Content-Type': 'application/json' }, + { Host: 'attacker.example', 'Content-Type': 'application/json' }, + ]; + for (const headers of rejectedHeaders) { + const response = await app.request('/api/stats/anime/1/merge', { + method: 'POST', + headers, + body: JSON.stringify({ sourceAnimeIds: [2] }), + }); + assert.equal(response.status, 403, JSON.stringify(headers)); + } + assert.equal(merges, 0); + for (const origin of [undefined, 'http://localhost']) { + const headers = new Headers({ 'Content-Type': 'application/json; charset=utf-8' }); + if (origin) headers.set('Origin', origin); + const response = await app.request('/api/stats/anime/1/merge', { + method: 'POST', + headers, + body: JSON.stringify({ sourceAnimeIds: [2] }), + }); + assert.equal(response.status, 200); + } + assert.equal(merges, 2); + }); + + it('requires JSON for mutation bodies and preserves bodyless deletion', async () => { + let deletions = 0; + const app = createStatsApp( + createMockTracker({ + deleteSession: async () => { + deletions += 1; + }, + }), + ); + const invalid = await app.request('/api/stats/sessions/1', { + method: 'DELETE', + body: '{}', + }); + assert.equal(invalid.status, 415); + assert.equal(deletions, 0); + const valid = await app.request('/api/stats/sessions/1', { method: 'DELETE' }); + assert.equal(valid.status, 200); + assert.equal(deletions, 1); + const rebound = await app.request('http://attacker.example/api/stats/sessions/1', { + method: 'DELETE', + headers: { Origin: 'http://attacker.example' }, + }); + assert.equal(rebound.status, 403); + assert.equal(deletions, 1); + }); + it('GET /api/stats/overview returns overview data', async () => { const app = createStatsApp(createMockTracker()); const res = await app.request('/api/stats/overview'); @@ -1153,7 +1220,7 @@ describe('stats server API routes', () => { body: JSON.stringify({ dryRun: false, lookbackDays: null }), }); - assert.equal(res.status, 415); + assert.equal(res.status, 403); assert.equal(cleanupCalls, 0); }); @@ -4128,4 +4195,60 @@ Aligned English subtitle }); } }); + + it('enforces request safety through node:http without rejecting bodyless DELETEs', async () => { + await withTempDir(async (staticDir) => { + let deletions = 0; + const tracker = createMockTracker({ + deleteSession: async () => { + deletions += 1; + }, + }); + const listener = http.createServer(); + const server = await startNodeHttpServer( + createStatsApp(tracker), + { port: 0, staticDir, tracker }, + (handler) => { + listener.on('request', handler); + return listener; + }, + ); + try { + const address = listener.address(); + assert.ok(address && typeof address !== 'string'); + const origin = `http://127.0.0.1:${address.port}`; + const url = `${origin}/api/stats/sessions/1`; + for (const headers of [undefined, { 'Content-Length': '0' }]) { + const response = await fetch(url, { method: 'DELETE', headers }); + assert.equal(response.status, 200); + await response.arrayBuffer(); + } + assert.equal(deletions, 2); + for (const headers of [ + new Headers({ Origin: 'https://attacker.example' }), + new Headers({ Origin: 'null' }), + new Headers({ Host: 'attacker.example' }), + new Headers({ 'Sec-Fetch-Site': 'same-site' }), + ]) { + const response = await fetch(url, { method: 'DELETE', headers }); + assert.equal(response.status, 403, JSON.stringify(headers)); + await response.arrayBuffer(); + } + const invalid = await fetch(url, { method: 'DELETE', body: '{}' }); + assert.equal(invalid.status, 415); + await invalid.arrayBuffer(); + assert.equal(deletions, 2); + const valid = await fetch(url, { + method: 'DELETE', + headers: { Origin: origin, 'Content-Type': 'application/json' }, + body: '{}', + }); + assert.equal(valid.status, 200); + await valid.arrayBuffer(); + assert.equal(deletions, 3); + } finally { + await server.close(); + } + }); + }); }); diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 66113ddd..3b39f634 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -6,6 +6,7 @@ import type { AnilistRateLimiter } from './anilist/rate-limiter.js'; import type { ImmersionTrackerService } from './immersion-tracker-service.js'; import type { RetimedSecondarySubtitleInput } from './secondary-subtitle-sidecar.js'; import type { StatsServerMediaGenerator } from './stats-server/mining-support.js'; +import { enforceStatsRequestSafety } from './stats-server/request-safety.js'; import { registerStatsAnalyticsRoutes, registerStatsIntegrationRoutes, @@ -37,7 +38,10 @@ function toFetchRequest(req: IncomingMessage): Request { method, headers: toFetchHeaders(req.headers), }; - if (method !== 'GET' && method !== 'HEAD') { + const hasBody = + req.headers['transfer-encoding'] !== undefined || + Number(req.headers['content-length'] ?? 0) > 0; + if (method !== 'GET' && method !== 'HEAD' && hasBody) { init.body = Readable.toWeb(req) as BodyInit; init.duplex = 'half'; } @@ -156,6 +160,7 @@ export function createStatsApp( }, ) { const app = new Hono(); + app.use('*', enforceStatsRequestSafety); registerStatsAnalyticsRoutes(app, tracker, options); registerStatsLibraryRoutes(app, tracker, options); registerStatsIntegrationRoutes(app, tracker, options); diff --git a/src/core/services/stats-server/request-safety.ts b/src/core/services/stats-server/request-safety.ts new file mode 100644 index 00000000..0e9e1ac8 --- /dev/null +++ b/src/core/services/stats-server/request-safety.ts @@ -0,0 +1,42 @@ +import type { MiddlewareHandler } from 'hono'; + +function isLoopbackUrl(url: URL): boolean { + return ( + url.protocol === 'http:' && + !url.username && + !url.password && + ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) + ); +} + +/** Protect the local API even when a browser can reach the loopback listener. */ +export const enforceStatsRequestSafety: MiddlewareHandler = async (c, next) => { + const url = new URL(c.req.url); + if (!isLoopbackUrl(url)) return c.body(null, 403); + + const host = c.req.header('host'); + if (host !== undefined) { + if (!/^(localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(host)) { + return c.body(null, 403); + } + // Node derives the request URL from Host; Bun provides them independently. + try { + if (new URL(`http://${host}`).origin !== url.origin) return c.body(null, 403); + } catch { + return c.body(null, 403); + } + } + + // Compare the serialized origin exactly. Opaque origins and malformed values + // containing credentials, paths, or multiple origins must not gain trust. + const origin = c.req.header('origin'); + if (origin !== undefined && origin !== url.origin) return c.body(null, 403); + const site = c.req.header('sec-fetch-site'); + if (site === 'cross-site' || site === 'same-site') return c.body(null, 403); + + if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method) && c.req.raw.body !== null) { + const contentType = c.req.header('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); + if (contentType !== 'application/json') return c.body(null, 415); + } + await next(); +}; diff --git a/src/core/services/stats-window-runtime.ts b/src/core/services/stats-window-runtime.ts index 4129f04e..82ef0fc6 100644 --- a/src/core/services/stats-window-runtime.ts +++ b/src/core/services/stats-window-runtime.ts @@ -219,13 +219,8 @@ export function scheduleStatsWindowPostShowReconciles( } } -export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): { - query: Record; -} { - return { - query: { - overlay: '1', - ...(apiBaseUrl ? { apiBase: apiBaseUrl } : {}), - }, - }; +export function buildStatsWindowUrl(apiBaseUrl: string): string { + const url = new URL('/', apiBaseUrl); + url.searchParams.set('overlay', '1'); + return url.toString(); } diff --git a/src/core/services/stats-window.test.ts b/src/core/services/stats-window.test.ts index b49ebe40..da1776bb 100644 --- a/src/core/services/stats-window.test.ts +++ b/src/core/services/stats-window.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { - buildStatsWindowLoadFileOptions, + buildStatsWindowUrl, buildStatsWindowOptions, buildStatsNativeConfirmDialogOptions, demoteVisibleStatsWindowBelowDialogs, @@ -168,21 +168,12 @@ test('shouldHideStatsWindowForInput matches Escape and configured bare toggle ke ); }); -test('buildStatsWindowLoadFileOptions enables overlay rendering mode', () => { - assert.deepEqual(buildStatsWindowLoadFileOptions(), { - query: { - overlay: '1', - }, - }); +test('buildStatsWindowUrl enables overlay rendering on the local HTTP origin', () => { + assert.equal(buildStatsWindowUrl('http://127.0.0.1:6969'), 'http://127.0.0.1:6969/?overlay=1'); }); -test('buildStatsWindowLoadFileOptions includes provided stats API base URL', () => { - assert.deepEqual(buildStatsWindowLoadFileOptions('http://127.0.0.1:6123'), { - query: { - overlay: '1', - apiBase: 'http://127.0.0.1:6123', - }, - }); +test('buildStatsWindowUrl uses the active server port as the document origin', () => { + assert.equal(buildStatsWindowUrl('http://127.0.0.1:6123'), 'http://127.0.0.1:6123/?overlay=1'); }); test('resolveStatsWindowOuterBoundsForContent compensates for Wayland content insets', () => { diff --git a/src/core/services/stats-window.ts b/src/core/services/stats-window.ts index fba13633..ab94af7a 100644 --- a/src/core/services/stats-window.ts +++ b/src/core/services/stats-window.ts @@ -1,10 +1,9 @@ import { BrowserWindow, dialog, ipcMain } from 'electron'; -import * as path from 'path'; import { createLogger } from '../../logger.js'; import type { WindowGeometry } from '../../types.js'; import { IPC_CHANNELS } from '../../shared/ipc/contracts.js'; import { - buildStatsWindowLoadFileOptions, + buildStatsWindowUrl, buildStatsWindowOptions, demoteVisibleStatsWindowBelowDialogs, presentStatsWindow, @@ -34,12 +33,10 @@ const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState(); const logger = createLogger('main:stats-window'); export interface StatsWindowOptions { - /** Absolute path to stats/dist/ directory */ - staticDir: string; /** Absolute path to the compiled preload-stats.js */ preloadPath: string; /** Resolve the active stats API base URL */ - getApiBaseUrl?: () => Promise | string; + getApiBaseUrl: () => Promise | string; /** Report server startup failure through the configured notification surface. */ onStartupError?: (error: unknown) => void; /** Resolve the active stats toggle key from config */ @@ -188,7 +185,7 @@ export async function toggleStatsOverlay(options: StatsWindowOptions): Promise options.getApiBaseUrl?.()) + .then(() => options.getApiBaseUrl()) .catch((error: unknown) => { options.onStartupError?.(error); throw error; @@ -207,8 +204,7 @@ export async function toggleStatsOverlay(options: StatsWindowOptions): Promise { options.onVisibilityChanged?.(false); diff --git a/src/main.ts b/src/main.ts index c3c60dd9..a85866d7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4138,7 +4138,6 @@ const immersionTrackerStartupMainDeps: Parameters< // Register stats overlay toggle IPC handler (idempotent) registerStatsOverlayToggle({ - staticDir: statsDistPath, preloadPath: statsPreloadPath, getApiBaseUrl: async () => (await ensureStatsServerStarted()).url, onStartupError: (error) => @@ -5442,7 +5441,6 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro await dispatchSessionActionCore(request, { toggleStatsOverlay: async () => await toggleStatsOverlayWindow({ - staticDir: statsDistPath, preloadPath: statsPreloadPath, getApiBaseUrl: async () => (await ensureStatsServerStarted()).url, onStartupError: (error) => diff --git a/stats/src/App.tsx b/stats/src/App.tsx index f2348d4c..018a8893 100644 --- a/stats/src/App.tsx +++ b/stats/src/App.tsx @@ -4,7 +4,6 @@ import { DeleteProgressToast } from './components/common/DeleteProgressToast'; import { TabBar } from './components/layout/TabBar'; import { OverviewTab } from './components/overview/OverviewTab'; import { useExcludedWords } from './hooks/useExcludedWords'; -import { assetUrl } from './lib/asset-url'; import type { TabId } from './components/layout/TabBar'; import { closeMediaDetail, @@ -142,7 +141,7 @@ export function App() { onClick={() => handleTabChange('overview')} className="flex items-center gap-2 mb-2 hover:opacity-80 transition-opacity" > - +

SubMiner Stats

diff --git a/stats/src/components/vocabulary/WordDetailPanel.tsx b/stats/src/components/vocabulary/WordDetailPanel.tsx index b44123b3..f1cd2ef4 100644 --- a/stats/src/components/vocabulary/WordDetailPanel.tsx +++ b/stats/src/components/vocabulary/WordDetailPanel.tsx @@ -1,7 +1,6 @@ import { useRef, useState, useEffect } from 'react'; import { useWordDetail } from '../../hooks/useWordDetail'; import { apiClient } from '../../lib/api-client'; -import { assetUrl } from '../../lib/asset-url'; import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters'; import { buildStatsMineCardParams, @@ -167,7 +166,7 @@ export function WordDetailPanel({ if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { new Notification('Anki Card Created', { body: `Mined: ${label}`, - icon: assetUrl('favicon.png'), + icon: '/favicon.png', }); } else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') { Notification.requestPermission().then((p) => { diff --git a/stats/src/lib/api-client.test.ts b/stats/src/lib/api-client.test.ts index 0f6092ad..014e6bf9 100644 --- a/stats/src/lib/api-client.test.ts +++ b/stats/src/lib/api-client.test.ts @@ -1,36 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { apiClient, BASE_URL, resolveStatsBaseUrl } from './api-client'; - -test('resolveStatsBaseUrl prefers apiBase query parameter for file-based overlay mode', () => { - const baseUrl = resolveStatsBaseUrl({ - protocol: 'file:', - origin: 'null', - search: '?overlay=1&apiBase=http%3A%2F%2F127.0.0.1%3A6123', - }); - - assert.equal(baseUrl, 'http://127.0.0.1:6123'); -}); - -test('resolveStatsBaseUrl falls back to configured window origin for browser mode', () => { - const baseUrl = resolveStatsBaseUrl({ - protocol: 'http:', - origin: 'http://127.0.0.1:6123', - search: '', - }); - - assert.equal(baseUrl, 'http://127.0.0.1:6123'); -}); - -test('resolveStatsBaseUrl keeps legacy localhost fallback for file mode without apiBase', () => { - const baseUrl = resolveStatsBaseUrl({ - protocol: 'file:', - origin: 'null', - search: '?overlay=1', - }); - - assert.equal(baseUrl, 'http://127.0.0.1:6969'); -}); +import { apiClient, BASE_URL } from './api-client'; test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => { const getAnimeCoverUrl = apiClient.getAnimeCoverUrl as ( @@ -38,10 +8,7 @@ test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => { retryToken?: number, ) => string; - assert.equal( - getAnimeCoverUrl(42, 3), - 'http://127.0.0.1:6969/api/stats/anime/42/cover?coverRetry=3', - ); + assert.equal(getAnimeCoverUrl(42, 3), `${BASE_URL}/api/stats/anime/42/cover?coverRetry=3`); }); test('getAnimeMergeRecommendations loads pending duplicate pairs', async () => { diff --git a/stats/src/lib/api-client.ts b/stats/src/lib/api-client.ts index addf6ef6..2d7186aa 100644 --- a/stats/src/lib/api-client.ts +++ b/stats/src/lib/api-client.ts @@ -23,24 +23,8 @@ import type { StatsMineCardParams, StatsMineCardResponse } from './mining'; import { appendCoverRetryToken } from './cover-retry'; import { trackDelete } from './delete-progress'; -type StatsLocationLike = Pick; - -export function resolveStatsBaseUrl(location?: StatsLocationLike): string { - const resolvedLocation = - location ?? - (typeof window === 'undefined' - ? { protocol: 'file:', origin: 'null', search: '' } - : window.location); - - const queryApiBase = new URLSearchParams(resolvedLocation.search).get('apiBase')?.trim(); - if (queryApiBase) { - return queryApiBase; - } - - return resolvedLocation.protocol === 'file:' ? 'http://127.0.0.1:6969' : resolvedLocation.origin; -} - -export const BASE_URL = resolveStatsBaseUrl(); +// Both browser and in-app dashboards use the server that served the page. +export const BASE_URL = typeof window === 'undefined' ? '' : window.location.origin; async function fetchResponse(path: string, init?: RequestInit): Promise { const res = await fetch(`${BASE_URL}${path}`, init); diff --git a/stats/src/lib/asset-url.test.ts b/stats/src/lib/asset-url.test.ts deleted file mode 100644 index 399773ed..00000000 --- a/stats/src/lib/asset-url.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { assetUrl, resolveAssetUrl } from './asset-url'; - -// vite.config.ts sets `base: './'`, so this is what the built bundle sees. -const BUILT_BASE = './'; - -test('built asset URLs are never root-absolute', () => { - assert.equal(resolveAssetUrl('favicon.png', BUILT_BASE).startsWith('/'), false); -}); - -test('built asset URL resolves next to a file:// index.html', () => { - const resolved = new URL( - resolveAssetUrl('favicon.png', BUILT_BASE), - 'file:///opt/SubMiner/stats/dist/index.html', - ); - assert.equal(resolved.href, 'file:///opt/SubMiner/stats/dist/favicon.png'); -}); - -test('built asset URL resolves against the server root when served over http', () => { - const resolved = new URL(resolveAssetUrl('favicon.png', BUILT_BASE), 'http://127.0.0.1:8770/'); - assert.equal(resolved.href, 'http://127.0.0.1:8770/favicon.png'); -}); - -test('dev server base stays root-absolute', () => { - assert.equal(resolveAssetUrl('favicon.png', '/'), '/favicon.png'); -}); - -test('a base without a trailing slash still joins cleanly', () => { - assert.equal(resolveAssetUrl('favicon.png', '/stats'), '/stats/favicon.png'); -}); - -test('a leading slash in the requested path is tolerated', () => { - assert.equal(resolveAssetUrl('/favicon.png', BUILT_BASE), './favicon.png'); -}); - -test('assetUrl falls back to a relative base outside a Vite bundle', () => { - assert.equal(assetUrl('favicon.png').startsWith('/'), false); -}); diff --git a/stats/src/lib/asset-url.ts b/stats/src/lib/asset-url.ts deleted file mode 100644 index 9a8e8d2f..00000000 --- a/stats/src/lib/asset-url.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Resolve a bundled public asset against Vite's base URL. - * - * The in-player stats window is loaded with `loadFile`, so the document lives on - * `file://`. A root-absolute path like `/favicon.png` resolves to the filesystem - * root there and 404s, while the HTTP-served web app resolves it fine. Vite - * rewrites asset refs in `index.html` but not string literals in JSX, so build - * the URL from the configured base instead of hardcoding a leading slash. - */ -export function resolveAssetUrl(path: string, base: string): string { - const normalizedBase = base.endsWith('/') ? base : `${base}/`; - return `${normalizedBase}${path.replace(/^\/+/, '')}`; -} - -function currentBase(): string { - // Vite injects BASE_URL at build time ('./' per vite.config.ts) and serves '/' - // in dev. Outside a Vite bundle (tests) there is no env, so fall back to './'. - const env = (import.meta as { env?: Record }).env; - return env?.BASE_URL || './'; -} - -export function assetUrl(path: string): string { - return resolveAssetUrl(path, currentBase()); -} diff --git a/stats/src/lib/media-library-grouping.test.tsx b/stats/src/lib/media-library-grouping.test.tsx index e57edf04..11448581 100644 --- a/stats/src/lib/media-library-grouping.test.tsx +++ b/stats/src/lib/media-library-grouping.test.tsx @@ -169,14 +169,14 @@ test('CoverImage renders explicit remote artwork when src is provided', () => { test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls', () => { const markup = renderToStaticMarkup( {}} />); - assert.match(markup, /src="http:\/\/127\.0\.0\.1:6969\/api\/stats\/media\/1\/cover"/); + assert.match(markup, /src="\/api\/stats\/media\/1\/cover"/); assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/); }); test('resolveMediaCoverApiUrl appends retry tokens for late cover refreshes', () => { assert.equal( resolveMediaCoverApiUrl(youtubeEpisodeA.videoId, 2), - 'http://127.0.0.1:6969/api/stats/media/1/cover?coverRetry=2', + '/api/stats/media/1/cover?coverRetry=2', ); });