fix(stats): enforce cleanup mode exclusivity and JSON requests

- Reject conflicting explicit cleanup modes
- Require application/json for duplicate-line maintenance requests
This commit is contained in:
2026-08-12 00:49:32 -07:00
parent 9fd2dfd7a0
commit 4a7c9b04f1
6 changed files with 54 additions and 7 deletions
+1 -1
View File
@@ -147,7 +147,7 @@ subminer stats cleanup --duplicate-lines --dry-run --lookback-days 30
subminer stats cleanup --duplicate-lines --lookback-days 30 subminer stats cleanup --duplicate-lines --lookback-days 30
``` ```
`--duplicate-lines` (short: `-d`) picks the cleanup mode, so it cannot be combined with `--lifetime`, and `--dry-run` and `--lookback-days <days>` only apply to it. Omitting `--lookback-days` scans all history; the value must be at least one day. `--duplicate-lines` (short: `-d`) picks the cleanup mode, so it cannot be combined with `--vocab` or `--lifetime`, and `--dry-run` and `--lookback-days <days>` only apply to it. Omitting `--lookback-days` scans all history; the value must be at least one day.
Runs never cross a session boundary, so rewatching an episode keeps both watches. Session telemetry (watch time, lines seen, tokens seen) and the rollups derived from it are left as recorded: they are cumulative samples taken during playback, and cannot be recomputed for sessions whose raw rows have since been pruned. Runs never cross a session boundary, so rewatching an episode keeps both watches. Session telemetry (watch time, lines seen, tokens seen) and the rollups derived from it are left as recorded: they are cumulative samples taken during playback, and cannot be recomputed for sessions whose raw rows have since been pruned.
+1 -1
View File
@@ -109,7 +109,7 @@ subminer app --stop # Stop the background app
subminer --version # Print the launcher's version subminer --version # Print the launcher's version
``` ```
`stats cleanup` runs one mode per invocation: `-v`/`--vocab` (the default), `-l`/`--lifetime`, or `-d`/`--duplicate-lines`, and `--lifetime` cannot be combined with `--duplicate-lines`. `--dry-run` and `--lookback-days <days>` apply to `--duplicate-lines` only and are rejected without it; `--lookback-days` must be at least one day, and leaving it off scans all history. `stats cleanup` runs one mode per invocation: `-v`/`--vocab` (the default), `-l`/`--lifetime`, or `-d`/`--duplicate-lines`; explicitly selected modes cannot be combined. `--dry-run` and `--lookback-days <days>` apply to `--duplicate-lines` only and are rejected without it; `--lookback-days` must be at least one day, and leaving it off scans all history.
Jellyfin, cross-machine sync, and character-dictionary commands have their own sections: [Jellyfin](/jellyfin-integration), [Sync Between Machines](/launcher-script#sync-between-machines), and [Character Dictionary](/character-dictionary). Jellyfin, cross-machine sync, and character-dictionary commands have their own sections: [Jellyfin](/jellyfin-integration), [Sync Between Machines](/launcher-script#sync-between-machines), and [Character Dictionary](/character-dictionary).
+8 -2
View File
@@ -316,14 +316,20 @@ export function parseCliPrograms(
'Stats --vocab, --lifetime and --duplicate-lines flags require the cleanup action.', 'Stats --vocab, --lifetime and --duplicate-lines flags require the cleanup action.',
); );
} }
if (options.duplicateLines !== true && (options.dryRun === true || options.lookbackDays)) { if (
options.duplicateLines !== true &&
(options.dryRun === true || options.lookbackDays !== undefined)
) {
throw new Error('Stats --dry-run and --lookback-days require --duplicate-lines.'); throw new Error('Stats --dry-run and --lookback-days require --duplicate-lines.');
} }
if (normalizedAction === 'cleanup') { if (normalizedAction === 'cleanup') {
statsCleanup = true; statsCleanup = true;
statsCleanupLifetime = options.lifetime === true; statsCleanupLifetime = options.lifetime === true;
statsCleanupDuplicateLines = options.duplicateLines === true; statsCleanupDuplicateLines = options.duplicateLines === true;
if (statsCleanupLifetime && statsCleanupDuplicateLines) { const explicitModeCount = [options.vocab, options.lifetime, options.duplicateLines].filter(
(value) => value === true,
).length;
if (explicitModeCount > 1) {
throw new Error('Stats cleanup runs one mode at a time.'); throw new Error('Stats cleanup runs one mode at a time.');
} }
// Vocabulary cleanup stays the default so `stats cleanup` keeps its old meaning. // Vocabulary cleanup stays the default so `stats cleanup` keeps its old meaning.
+18 -3
View File
@@ -262,13 +262,28 @@ test('parseArgs rejects duplicate-line flags without the duplicate-lines mode',
assert.match(error.stderr, /--dry-run and --lookback-days require --duplicate-lines/); assert.match(error.stderr, /--dry-run and --lookback-days require --duplicate-lines/);
}); });
test('parseArgs rejects combining lifetime and duplicate-line cleanup modes', () => { test('parseArgs rejects an empty lookback value outside duplicate-line cleanup', () => {
const error = withProcessExitIntercept(() => { const error = withProcessExitIntercept(() => {
parseArgs(['stats', 'cleanup', '--lifetime', '--duplicate-lines'], 'subminer', {}); parseArgs(['stats', '--lookback-days', ''], 'subminer', {});
}); });
assert.equal(error.code, 1); assert.equal(error.code, 1);
assert.match(error.stderr, /Stats cleanup runs one mode at a time/); assert.match(error.stderr, /--dry-run and --lookback-days require --duplicate-lines/);
});
test('parseArgs rejects combining explicit cleanup modes', () => {
for (const modes of [
['--lifetime', '--duplicate-lines'],
['--vocab', '--duplicate-lines'],
['--vocab', '--lifetime'],
]) {
const error = withProcessExitIntercept(() => {
parseArgs(['stats', 'cleanup', ...modes], 'subminer', {});
});
assert.equal(error.code, 1);
assert.match(error.stderr, /Stats cleanup runs one mode at a time/);
}
}); });
test('parseArgs rejects unusable lookback windows', () => { test('parseArgs rejects unusable lookback windows', () => {
@@ -1064,6 +1064,30 @@ describe('stats server API routes', () => {
assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: 30 }); assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: 30 });
}); });
it('POST /api/stats/maintenance/duplicate-lines rejects cross-origin simple requests', async () => {
let cleanupCalls = 0;
const app = createStatsApp(
createMockTracker({
cleanupDuplicateSubtitleLines: async () => {
cleanupCalls += 1;
throw new Error('cleanup must not run');
},
}),
);
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
Origin: 'https://attacker.example',
},
body: JSON.stringify({ dryRun: false, lookbackDays: null }),
});
assert.equal(res.status, 415);
assert.equal(cleanupCalls, 0);
});
it('POST /api/stats/maintenance/duplicate-lines rejects a window shorter than a day', async () => { it('POST /api/stats/maintenance/duplicate-lines rejects a window shorter than a day', async () => {
let cleanupCalls = 0; let cleanupCalls = 0;
const app = createStatsApp( const app = createStatsApp(
@@ -44,6 +44,8 @@ export function registerStatsLibraryRoutes(
// Collapse animation bursts older versions recorded frame by frame. `dryRun` measures // Collapse animation bursts older versions recorded frame by frame. `dryRun` measures
// the same scan without writing, so the confirmation the user sees is the real cost. // the same scan without writing, so the confirmation the user sees is the real cost.
app.post('/api/stats/maintenance/duplicate-lines', async (c) => { app.post('/api/stats/maintenance/duplicate-lines', async (c) => {
const contentType = c.req.header('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
if (contentType !== 'application/json') return c.body(null, 415);
const body = await c.req.json().catch(() => null); const body = await c.req.json().catch(() => null);
const options = parseDuplicateLineCleanupBody(body); const options = parseDuplicateLineCleanupBody(body);
if (!options) return c.body(null, 400); if (!options) return c.body(null, 400);