fix(stats): restrict local requests and serve the dashboard over HTTP (#263)

This commit is contained in:
2026-09-20 23:36:55 -07:00
committed by GitHub
parent 2f21582666
commit ab48a5678e
16 changed files with 221 additions and 157 deletions
+1 -2
View File
@@ -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"
>
<img src={assetUrl('favicon.png')} alt="" className="h-6 object-contain" />
<img src={'/favicon.png'} alt="" className="h-6 object-contain" />
<h1 className="text-lg font-semibold text-ctp-text">SubMiner Stats</h1>
</button>
<TabBar activeTab={activeTab} onTabChange={handleTabChange} />
@@ -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) => {
+2 -35
View File
@@ -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 () => {
+2 -18
View File
@@ -23,24 +23,8 @@ import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
import { appendCoverRetryToken } from './cover-retry';
import { trackDelete } from './delete-progress';
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
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<Response> {
const res = await fetch(`${BASE_URL}${path}`, init);
-39
View File
@@ -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);
});
-24
View File
@@ -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<string, string | undefined> }).env;
return env?.BASE_URL || './';
}
export function assetUrl(path: string): string {
return resolveAssetUrl(path, currentBase());
}
@@ -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(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
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',
);
});