mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 05:16:20 -07:00
fix(stats): restrict local requests and serve the dashboard over HTTP (#263)
This commit is contained in:
@@ -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.
|
||||||
@@ -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 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`).
|
- 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`).
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -444,6 +444,73 @@ async function withFakeAnkiConnect<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('stats server API routes', () => {
|
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<string, string>[] = [
|
||||||
|
{ 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 () => {
|
it('GET /api/stats/overview returns overview data', async () => {
|
||||||
const app = createStatsApp(createMockTracker());
|
const app = createStatsApp(createMockTracker());
|
||||||
const res = await app.request('/api/stats/overview');
|
const res = await app.request('/api/stats/overview');
|
||||||
@@ -1153,7 +1220,7 @@ describe('stats server API routes', () => {
|
|||||||
body: JSON.stringify({ dryRun: false, lookbackDays: null }),
|
body: JSON.stringify({ dryRun: false, lookbackDays: null }),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(res.status, 415);
|
assert.equal(res.status, 403);
|
||||||
assert.equal(cleanupCalls, 0);
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
|
|||||||
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
||||||
import type { RetimedSecondarySubtitleInput } from './secondary-subtitle-sidecar.js';
|
import type { RetimedSecondarySubtitleInput } from './secondary-subtitle-sidecar.js';
|
||||||
import type { StatsServerMediaGenerator } from './stats-server/mining-support.js';
|
import type { StatsServerMediaGenerator } from './stats-server/mining-support.js';
|
||||||
|
import { enforceStatsRequestSafety } from './stats-server/request-safety.js';
|
||||||
import {
|
import {
|
||||||
registerStatsAnalyticsRoutes,
|
registerStatsAnalyticsRoutes,
|
||||||
registerStatsIntegrationRoutes,
|
registerStatsIntegrationRoutes,
|
||||||
@@ -37,7 +38,10 @@ function toFetchRequest(req: IncomingMessage): Request {
|
|||||||
method,
|
method,
|
||||||
headers: toFetchHeaders(req.headers),
|
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.body = Readable.toWeb(req) as BodyInit;
|
||||||
init.duplex = 'half';
|
init.duplex = 'half';
|
||||||
}
|
}
|
||||||
@@ -156,6 +160,7 @@ export function createStatsApp(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
app.use('*', enforceStatsRequestSafety);
|
||||||
registerStatsAnalyticsRoutes(app, tracker, options);
|
registerStatsAnalyticsRoutes(app, tracker, options);
|
||||||
registerStatsLibraryRoutes(app, tracker, options);
|
registerStatsLibraryRoutes(app, tracker, options);
|
||||||
registerStatsIntegrationRoutes(app, tracker, options);
|
registerStatsIntegrationRoutes(app, tracker, options);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -219,13 +219,8 @@ export function scheduleStatsWindowPostShowReconciles(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
|
export function buildStatsWindowUrl(apiBaseUrl: string): string {
|
||||||
query: Record<string, string>;
|
const url = new URL('/', apiBaseUrl);
|
||||||
} {
|
url.searchParams.set('overlay', '1');
|
||||||
return {
|
return url.toString();
|
||||||
query: {
|
|
||||||
overlay: '1',
|
|
||||||
...(apiBaseUrl ? { apiBase: apiBaseUrl } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
buildStatsWindowLoadFileOptions,
|
buildStatsWindowUrl,
|
||||||
buildStatsWindowOptions,
|
buildStatsWindowOptions,
|
||||||
buildStatsNativeConfirmDialogOptions,
|
buildStatsNativeConfirmDialogOptions,
|
||||||
demoteVisibleStatsWindowBelowDialogs,
|
demoteVisibleStatsWindowBelowDialogs,
|
||||||
@@ -168,21 +168,12 @@ test('shouldHideStatsWindowForInput matches Escape and configured bare toggle ke
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildStatsWindowLoadFileOptions enables overlay rendering mode', () => {
|
test('buildStatsWindowUrl enables overlay rendering on the local HTTP origin', () => {
|
||||||
assert.deepEqual(buildStatsWindowLoadFileOptions(), {
|
assert.equal(buildStatsWindowUrl('http://127.0.0.1:6969'), 'http://127.0.0.1:6969/?overlay=1');
|
||||||
query: {
|
|
||||||
overlay: '1',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildStatsWindowLoadFileOptions includes provided stats API base URL', () => {
|
test('buildStatsWindowUrl uses the active server port as the document origin', () => {
|
||||||
assert.deepEqual(buildStatsWindowLoadFileOptions('http://127.0.0.1:6123'), {
|
assert.equal(buildStatsWindowUrl('http://127.0.0.1:6123'), 'http://127.0.0.1:6123/?overlay=1');
|
||||||
query: {
|
|
||||||
overlay: '1',
|
|
||||||
apiBase: 'http://127.0.0.1:6123',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveStatsWindowOuterBoundsForContent compensates for Wayland content insets', () => {
|
test('resolveStatsWindowOuterBoundsForContent compensates for Wayland content insets', () => {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { BrowserWindow, dialog, ipcMain } from 'electron';
|
import { BrowserWindow, dialog, ipcMain } from 'electron';
|
||||||
import * as path from 'path';
|
|
||||||
import { createLogger } from '../../logger.js';
|
import { createLogger } from '../../logger.js';
|
||||||
import type { WindowGeometry } from '../../types.js';
|
import type { WindowGeometry } from '../../types.js';
|
||||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
|
||||||
import {
|
import {
|
||||||
buildStatsWindowLoadFileOptions,
|
buildStatsWindowUrl,
|
||||||
buildStatsWindowOptions,
|
buildStatsWindowOptions,
|
||||||
demoteVisibleStatsWindowBelowDialogs,
|
demoteVisibleStatsWindowBelowDialogs,
|
||||||
presentStatsWindow,
|
presentStatsWindow,
|
||||||
@@ -34,12 +33,10 @@ const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState();
|
|||||||
const logger = createLogger('main:stats-window');
|
const logger = createLogger('main:stats-window');
|
||||||
|
|
||||||
export interface StatsWindowOptions {
|
export interface StatsWindowOptions {
|
||||||
/** Absolute path to stats/dist/ directory */
|
|
||||||
staticDir: string;
|
|
||||||
/** Absolute path to the compiled preload-stats.js */
|
/** Absolute path to the compiled preload-stats.js */
|
||||||
preloadPath: string;
|
preloadPath: string;
|
||||||
/** Resolve the active stats API base URL */
|
/** Resolve the active stats API base URL */
|
||||||
getApiBaseUrl?: () => Promise<string> | string;
|
getApiBaseUrl: () => Promise<string> | string;
|
||||||
/** Report server startup failure through the configured notification surface. */
|
/** Report server startup failure through the configured notification surface. */
|
||||||
onStartupError?: (error: unknown) => void;
|
onStartupError?: (error: unknown) => void;
|
||||||
/** Resolve the active stats toggle key from config */
|
/** Resolve the active stats toggle key from config */
|
||||||
@@ -188,7 +185,7 @@ export async function toggleStatsOverlay(options: StatsWindowOptions): Promise<v
|
|||||||
if (!statsWindow) {
|
if (!statsWindow) {
|
||||||
const generation = statsWindowGeneration;
|
const generation = statsWindowGeneration;
|
||||||
const apiBaseUrl = await Promise.resolve()
|
const apiBaseUrl = await Promise.resolve()
|
||||||
.then(() => options.getApiBaseUrl?.())
|
.then(() => options.getApiBaseUrl())
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
options.onStartupError?.(error);
|
options.onStartupError?.(error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -207,8 +204,7 @@ export async function toggleStatsOverlay(options: StatsWindowOptions): Promise<v
|
|||||||
statsWindow?.setTitle(STATS_WINDOW_TITLE);
|
statsWindow?.setTitle(STATS_WINDOW_TITLE);
|
||||||
});
|
});
|
||||||
|
|
||||||
const indexPath = path.join(options.staticDir, 'index.html');
|
statsWindow.loadURL(buildStatsWindowUrl(apiBaseUrl));
|
||||||
statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(apiBaseUrl));
|
|
||||||
|
|
||||||
statsWindow.on('closed', () => {
|
statsWindow.on('closed', () => {
|
||||||
options.onVisibilityChanged?.(false);
|
options.onVisibilityChanged?.(false);
|
||||||
|
|||||||
@@ -4138,7 +4138,6 @@ const immersionTrackerStartupMainDeps: Parameters<
|
|||||||
|
|
||||||
// Register stats overlay toggle IPC handler (idempotent)
|
// Register stats overlay toggle IPC handler (idempotent)
|
||||||
registerStatsOverlayToggle({
|
registerStatsOverlayToggle({
|
||||||
staticDir: statsDistPath,
|
|
||||||
preloadPath: statsPreloadPath,
|
preloadPath: statsPreloadPath,
|
||||||
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
|
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
|
||||||
onStartupError: (error) =>
|
onStartupError: (error) =>
|
||||||
@@ -5442,7 +5441,6 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
|||||||
await dispatchSessionActionCore(request, {
|
await dispatchSessionActionCore(request, {
|
||||||
toggleStatsOverlay: async () =>
|
toggleStatsOverlay: async () =>
|
||||||
await toggleStatsOverlayWindow({
|
await toggleStatsOverlayWindow({
|
||||||
staticDir: statsDistPath,
|
|
||||||
preloadPath: statsPreloadPath,
|
preloadPath: statsPreloadPath,
|
||||||
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
|
getApiBaseUrl: async () => (await ensureStatsServerStarted()).url,
|
||||||
onStartupError: (error) =>
|
onStartupError: (error) =>
|
||||||
|
|||||||
+1
-2
@@ -4,7 +4,6 @@ import { DeleteProgressToast } from './components/common/DeleteProgressToast';
|
|||||||
import { TabBar } from './components/layout/TabBar';
|
import { TabBar } from './components/layout/TabBar';
|
||||||
import { OverviewTab } from './components/overview/OverviewTab';
|
import { OverviewTab } from './components/overview/OverviewTab';
|
||||||
import { useExcludedWords } from './hooks/useExcludedWords';
|
import { useExcludedWords } from './hooks/useExcludedWords';
|
||||||
import { assetUrl } from './lib/asset-url';
|
|
||||||
import type { TabId } from './components/layout/TabBar';
|
import type { TabId } from './components/layout/TabBar';
|
||||||
import {
|
import {
|
||||||
closeMediaDetail,
|
closeMediaDetail,
|
||||||
@@ -142,7 +141,7 @@ export function App() {
|
|||||||
onClick={() => handleTabChange('overview')}
|
onClick={() => handleTabChange('overview')}
|
||||||
className="flex items-center gap-2 mb-2 hover:opacity-80 transition-opacity"
|
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>
|
<h1 className="text-lg font-semibold text-ctp-text">SubMiner Stats</h1>
|
||||||
</button>
|
</button>
|
||||||
<TabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
<TabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useRef, useState, useEffect } from 'react';
|
import { useRef, useState, useEffect } from 'react';
|
||||||
import { useWordDetail } from '../../hooks/useWordDetail';
|
import { useWordDetail } from '../../hooks/useWordDetail';
|
||||||
import { apiClient } from '../../lib/api-client';
|
import { apiClient } from '../../lib/api-client';
|
||||||
import { assetUrl } from '../../lib/asset-url';
|
|
||||||
import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters';
|
import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||||
import {
|
import {
|
||||||
buildStatsMineCardParams,
|
buildStatsMineCardParams,
|
||||||
@@ -167,7 +166,7 @@ export function WordDetailPanel({
|
|||||||
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||||||
new Notification('Anki Card Created', {
|
new Notification('Anki Card Created', {
|
||||||
body: `Mined: ${label}`,
|
body: `Mined: ${label}`,
|
||||||
icon: assetUrl('favicon.png'),
|
icon: '/favicon.png',
|
||||||
});
|
});
|
||||||
} else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') {
|
} else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') {
|
||||||
Notification.requestPermission().then((p) => {
|
Notification.requestPermission().then((p) => {
|
||||||
|
|||||||
@@ -1,36 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { apiClient, BASE_URL, resolveStatsBaseUrl } from './api-client';
|
import { apiClient, BASE_URL } 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');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
|
test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
|
||||||
const getAnimeCoverUrl = apiClient.getAnimeCoverUrl as (
|
const getAnimeCoverUrl = apiClient.getAnimeCoverUrl as (
|
||||||
@@ -38,10 +8,7 @@ test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
|
|||||||
retryToken?: number,
|
retryToken?: number,
|
||||||
) => string;
|
) => string;
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(getAnimeCoverUrl(42, 3), `${BASE_URL}/api/stats/anime/42/cover?coverRetry=3`);
|
||||||
getAnimeCoverUrl(42, 3),
|
|
||||||
'http://127.0.0.1:6969/api/stats/anime/42/cover?coverRetry=3',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getAnimeMergeRecommendations loads pending duplicate pairs', async () => {
|
test('getAnimeMergeRecommendations loads pending duplicate pairs', async () => {
|
||||||
|
|||||||
@@ -23,24 +23,8 @@ import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
|
|||||||
import { appendCoverRetryToken } from './cover-retry';
|
import { appendCoverRetryToken } from './cover-retry';
|
||||||
import { trackDelete } from './delete-progress';
|
import { trackDelete } from './delete-progress';
|
||||||
|
|
||||||
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
|
// Both browser and in-app dashboards use the server that served the page.
|
||||||
|
export const BASE_URL = typeof window === 'undefined' ? '' : window.location.origin;
|
||||||
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();
|
|
||||||
|
|
||||||
async function fetchResponse(path: string, init?: RequestInit): Promise<Response> {
|
async function fetchResponse(path: string, init?: RequestInit): Promise<Response> {
|
||||||
const res = await fetch(`${BASE_URL}${path}`, init);
|
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', () => {
|
test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls', () => {
|
||||||
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
|
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/);
|
assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveMediaCoverApiUrl appends retry tokens for late cover refreshes', () => {
|
test('resolveMediaCoverApiUrl appends retry tokens for late cover refreshes', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveMediaCoverApiUrl(youtubeEpisodeA.videoId, 2),
|
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