mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-13 01:55:50 -07:00
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:
@@ -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
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user