mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
fix(stats): restrict local requests and serve the dashboard over HTTP (#263)
This commit is contained in:
+1
-2
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user