diff --git a/changes/fix-stats-resource-id-validation.md b/changes/fix-stats-resource-id-validation.md new file mode 100644 index 00000000..c41ed3d1 --- /dev/null +++ b/changes/fix-stats-resource-id-validation.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Reject malformed resource IDs and partly invalid ID lists before stats library mutations or cover backfills run. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index 230d57a9..208918b7 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -37,6 +37,10 @@ The same immersion data powers the stats dashboard. - Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data. - Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running. +### Stats API resource IDs + +Resource IDs in URLs must be positive safe integers written as decimal digits without leading zeros, fractions, or exponent notation. ID lists in JSON bodies must contain positive safe integer numbers. Invalid IDs or list entries return `400` before any mutation; bulk requests do not apply just the valid subset. Pagination limits keep their existing rounding and bounds. + ### Dashboard tabs #### Overview diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 4f5d2873..b4264187 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -1004,6 +1004,23 @@ describe('stats server API routes', () => { assert.equal(seenLimit, 500); }); + it('GET /api/stats/vocabulary floors fractional pagination limits', async () => { + let seenLimit = 0; + const app = createStatsApp( + createMockTracker({ + getVocabularyStats: async (limit?: number) => { + seenLimit = limit ?? 0; + return VOCABULARY_STATS; + }, + }), + ); + + const res = await app.request('/api/stats/vocabulary?limit=12.9'); + + assert.equal(res.status, 200); + assert.equal(seenLimit, 12); + }); + it('GET /api/stats/vocabulary passes excludePos to tracker', async () => { let seenArgs: unknown[] = []; const app = createStatsApp( @@ -1351,7 +1368,7 @@ describe('stats server API routes', () => { }), ); - for (const anilistId of [-1, 0, 1.5, '12', true, undefined]) { + for (const anilistId of [-1, 0, 1.5, 9_007_199_254_740_992, '12', true, undefined]) { const res = await app.request('/api/stats/anime/1/anilist', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -1449,6 +1466,74 @@ describe('stats server API routes', () => { assert.equal(res.status, 404); }); + it('resource routes reject fractional ids before calling dependencies', async () => { + const dependencyCalls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + dependencyCalls.push('fetch'); + return new Response('{}', { status: 200 }); + }; + + try { + const app = createStatsApp( + createMockTracker({ + getWordDetail: async () => { + dependencyCalls.push('getWordDetail'); + return null; + }, + getSessionEvents: async () => { + dependencyCalls.push('getSessionEvents'); + return []; + }, + getEpisodeSessions: async () => { + dependencyCalls.push('getEpisodeSessions'); + return []; + }, + getAnimeCoverArt: async () => { + dependencyCalls.push('getAnimeCoverArt'); + return null; + }, + ensureAnimeCoverArt: async () => { + dependencyCalls.push('ensureAnimeCoverArt'); + return false; + }, + setVideoWatched: async () => { + dependencyCalls.push('setVideoWatched'); + }, + reassignAnimeAnilist: async () => { + dependencyCalls.push('reassignAnimeAnilist'); + }, + }), + ); + + const responses = await Promise.all([ + app.request('/api/stats/vocabulary/1.9/detail'), + app.request('/api/stats/sessions/1.9/events'), + app.request('/api/stats/episode/1.9/detail'), + app.request('/api/stats/anime/1.9/cover'), + app.request('/api/stats/media/1.9/watched', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: '{"watched":true}', + }), + app.request('/api/stats/anime/1.9/anilist', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: '{"anilistId":21858}', + }), + app.request('/api/stats/anki/browse?noteId=1.9', { method: 'POST' }), + ]); + + assert.deepEqual( + responses.map((response) => response.status), + [400, 400, 400, 400, 400, 400, 400], + ); + assert.deepEqual(dependencyCalls, []); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('POST /api/stats/covers batches stored cover art and backfills missing anime art in the background', async () => { let ensureCoverArtCalls = 0; const ensureAnimeCoverArtCalls: number[] = []; @@ -1505,6 +1590,58 @@ describe('stats server API routes', () => { assert.deepEqual(ensureAnimeCoverArtCalls, [99999]); }); + it('JSON id lists reject malformed members before side effects', async () => { + const dependencyCalls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + dependencyCalls.push('fetch'); + return new Response('{}', { status: 200 }); + }; + + try { + const app = createStatsApp( + createMockTracker({ + deleteSessions: async () => { + dependencyCalls.push('deleteSessions'); + }, + mergeAnime: async () => { + dependencyCalls.push('mergeAnime'); + return { survivingAnimeId: 7, mergedAnimeIds: [], movedVideos: 0 }; + }, + getAnimeCoverArt: async () => { + dependencyCalls.push('getAnimeCoverArt'); + return null; + }, + ensureAnimeCoverArt: async () => { + dependencyCalls.push('ensureAnimeCoverArt'); + return false; + }, + }), + ); + const request = async (path: string, body: string, method = 'POST'): Promise => + await app.request(path, { + method, + headers: { 'Content-Type': 'application/json' }, + body, + }); + + const responses = await Promise.all([ + request('/api/stats/sessions', '{"sessionIds":[4,1.9,7]}', 'DELETE'), + request('/api/stats/anime/7/merge', '{"sourceAnimeIds":[8,"9"]}'), + request('/api/stats/covers', '{"animeIds":[1,1.9]}'), + request('/api/stats/anki/notesInfo', '{"noteIds":[1,1.9]}'), + ]); + + assert.deepEqual( + responses.map((response) => response.status), + [400, 400, 400, 400], + ); + assert.deepEqual(dependencyCalls, []); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('POST /api/stats/covers limits concurrent missing anime cover backfills', async () => { let activeBackfills = 0; let maxActiveBackfills = 0; @@ -3316,6 +3453,46 @@ Aligned English subtitle assert.equal(deleteCalls, 0); }); + it('DELETE /api/stats/sessions rejects a partly invalid id list without deleting', async () => { + let deleteCalls = 0; + const app = createStatsApp( + createMockTracker({ + deleteSessions: async () => { + deleteCalls += 1; + }, + }), + ); + + const res = await app.request('/api/stats/sessions', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: '{"sessionIds":[4,1.9,7]}', + }); + + assert.equal(res.status, 400); + assert.equal(deleteCalls, 0); + }); + + it('DELETE /api/stats/sessions deduplicates valid ids', async () => { + let deletedSessionIds: number[] = []; + const app = createStatsApp( + createMockTracker({ + deleteSessions: async (sessionIds: number[]) => { + deletedSessionIds = sessionIds; + }, + }), + ); + + const res = await app.request('/api/stats/sessions', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: '{"sessionIds":[4,4,7]}', + }); + + assert.equal(res.status, 200); + assert.deepEqual(deletedSessionIds, [4, 7]); + }); + it('DELETE /api/stats/anime/:animeId deletes the whole library entry', async () => { let deletedAnimeId: number | null = null; const app = createStatsApp( @@ -3349,6 +3526,33 @@ Aligned English subtitle assert.equal(deleteCalls, 0); }); + it('DELETE /api/stats/anime/:animeId rejects malformed anime ids before deleting', async () => { + let deletedAnimeId: number | null = null; + const app = createStatsApp( + createMockTracker({ + deleteAnime: async (animeId: number) => { + deletedAnimeId = animeId; + }, + }), + ); + + for (const animeId of [ + '1.9', + '1.0', + '1e2', + '9007199254740992', + '1%0A', + '%201', + '01', + '+1', + '0x1', + ]) { + const res = await app.request(`/api/stats/anime/${animeId}`, { method: 'DELETE' }); + assert.equal(res.status, 400, `accepted malformed anime id: ${animeId}`); + } + assert.equal(deletedAnimeId, null); + }); + it('POST /api/stats/anime/:animeId/merge folds the given entries into the target', async () => { let merged: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null; const app = createStatsApp( diff --git a/src/core/services/stats-cover-routes.ts b/src/core/services/stats-cover-routes.ts index 3e70f4f3..7d4f71aa 100644 --- a/src/core/services/stats-cover-routes.ts +++ b/src/core/services/stats-cover-routes.ts @@ -3,37 +3,13 @@ import type { Hono } from 'hono'; import type { ImmersionTrackerService } from './immersion-tracker-service.js'; import { statsJson, type StatsCoverImagesRequest } from '../../types/stats-http-contract.js'; import type { StatsCoverImage } from '../../types/stats-wire.js'; +import { parsePositiveId, parsePositiveIdList } from './stats-server/route-support.js'; type StatsCoverImagePayload = StatsCoverImage | null; type StatsCoverBatchBody = Partial>; const MAX_BACKGROUND_ANIME_COVER_FETCHES = 3; -function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number { - if (raw === undefined) return fallback; - const n = Number(raw); - if (!Number.isFinite(n) || n < 0) { - return fallback; - } - const parsed = Math.floor(n); - return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit); -} - -function parsePositiveIdList(raw: unknown, maxItems = 100): number[] { - if (!Array.isArray(raw)) return []; - - const ids = new Set(); - for (const rawId of raw) { - const id = typeof rawId === 'number' ? rawId : typeof rawId === 'string' ? Number(rawId) : NaN; - if (Number.isFinite(id) && id > 0) { - ids.add(Math.floor(id)); - if (ids.size >= maxItems) break; - } - } - - return Array.from(ids).sort((a, b) => a - b); -} - function coverImagePayload( art: { coverBlob?: Uint8Array | null } | null | undefined, ): StatsCoverImagePayload { @@ -129,8 +105,11 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer app.post('/api/stats/covers', async (c) => { const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null; - const animeIds = parsePositiveIdList(body?.animeIds); - const videoIds = parsePositiveIdList(body?.videoIds); + const animeIds = body?.animeIds === undefined ? [] : parsePositiveIdList(body.animeIds, 100); + const videoIds = body?.videoIds === undefined ? [] : parsePositiveIdList(body.videoIds, 100); + if (!animeIds || !videoIds) return c.body(null, 400); + animeIds.sort((a, b) => a - b); + videoIds.sort((a, b) => a - b); const anime: Record = {}; const media: Record = {}; @@ -155,8 +134,8 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer }); app.get('/api/stats/anime/:animeId/cover', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) return c.body(null, 404); + const animeId = parsePositiveId(c.req.param('animeId')); + if (animeId === null) return c.body(null, 400); let art = await tracker.getAnimeCoverArt(animeId); if (!art?.coverBlob) { await tracker.ensureAnimeCoverArt(animeId); @@ -167,8 +146,8 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer }); app.get('/api/stats/media/:videoId/cover', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.body(null, 404); + const videoId = parsePositiveId(c.req.param('videoId')); + if (videoId === null) return c.body(null, 400); let art = await tracker.getCoverArt(videoId); if (!art?.coverBlob) { await tracker.ensureCoverArt(videoId); diff --git a/src/core/services/stats-server/analytics-routes.ts b/src/core/services/stats-server/analytics-routes.ts index 6a9f3e6e..640915da 100644 --- a/src/core/services/stats-server/analytics-routes.ts +++ b/src/core/services/stats-server/analytics-routes.ts @@ -6,6 +6,7 @@ import { loadKnownWordsSet, parseEventTypesQuery, parseIntQuery, + parsePositiveId, parseTrendFillEmpty, parseTrendGroupBy, parseTrendRange, @@ -83,8 +84,8 @@ export function registerStatsAnalyticsRoutes( }); app.get('/api/stats/sessions/:id/timeline', async (c) => { - const id = parseIntQuery(c.req.param('id'), 0); - if (id <= 0) return c.json(statsJson('sessionTimeline', []), 400); + const id = parsePositiveId(c.req.param('id')); + if (id === null) return c.json(statsJson('sessionTimeline', []), 400); const rawLimit = c.req.query('limit'); const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000); const timeline = await tracker.getSessionTimeline(id, limit); @@ -92,8 +93,8 @@ export function registerStatsAnalyticsRoutes( }); app.get('/api/stats/sessions/:id/events', async (c) => { - const id = parseIntQuery(c.req.param('id'), 0); - if (id <= 0) return c.json(statsJson('sessionEvents', []), 400); + const id = parsePositiveId(c.req.param('id')); + if (id === null) return c.json(statsJson('sessionEvents', []), 400); const limit = parseIntQuery(c.req.query('limit'), 500, 1000); const eventTypes = parseEventTypesQuery(c.req.query('types')); const events = await tracker.getSessionEvents(id, limit, eventTypes); @@ -101,8 +102,8 @@ export function registerStatsAnalyticsRoutes( }); app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => { - const id = parseIntQuery(c.req.param('id'), 0); - if (id <= 0) return c.json(statsJson('sessionKnownWordsTimeline', []), 400); + const id = parsePositiveId(c.req.param('id')); + if (id === null) return c.json(statsJson('sessionKnownWordsTimeline', []), 400); const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath) ?? new Set(); diff --git a/src/core/services/stats-server/integration-routes.ts b/src/core/services/stats-server/integration-routes.ts index cdc26510..ed71b2b1 100644 --- a/src/core/services/stats-server/integration-routes.ts +++ b/src/core/services/stats-server/integration-routes.ts @@ -12,8 +12,10 @@ import { buildAnkiNotePreview, countKnownWords, enrichSessionsWithKnownWordMetrics, + isPositiveSafeInteger, loadKnownWordsSet, - parseIntQuery, + parsePositiveId, + parsePositiveIdList, } from './route-support.js'; const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000; @@ -87,8 +89,8 @@ export function registerStatsIntegrationRoutes( }); app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) { + const animeId = parsePositiveId(c.req.param('animeId')); + if (animeId === null) { return c.json( statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }), 400, @@ -105,8 +107,8 @@ export function registerStatsIntegrationRoutes( }); app.get('/api/stats/media/:videoId/known-words-summary', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) { + const videoId = parsePositiveId(c.req.param('videoId')); + if (videoId === null) { return c.json( statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }), 400, @@ -123,14 +125,10 @@ export function registerStatsIntegrationRoutes( }); app.patch('/api/stats/anime/:animeId/anilist', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) return c.body(null, 400); + const animeId = parsePositiveId(c.req.param('animeId')); + if (animeId === null) return c.body(null, 400); const body = await c.req.json().catch(() => null); - if ( - typeof body?.anilistId !== 'number' || - !Number.isInteger(body.anilistId) || - body.anilistId <= 0 - ) { + if (!isPositiveSafeInteger(body?.anilistId)) { return c.body(null, 400); } await tracker.reassignAnimeAnilist(animeId, body); @@ -140,8 +138,8 @@ export function registerStatsIntegrationRoutes( registerStatsCoverRoutes(app, tracker); app.get('/api/stats/episode/:videoId/detail', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.body(null, 400); + const videoId = parsePositiveId(c.req.param('videoId')); + if (videoId === null) return c.body(null, 400); const rawSessions = await tracker.getEpisodeSessions(videoId); const words = await tracker.getEpisodeWords(videoId); const cardEvents = await tracker.getEpisodeCardEvents(videoId); @@ -154,8 +152,8 @@ export function registerStatsIntegrationRoutes( }); app.post('/api/stats/anki/browse', async (c) => { - const noteId = parseIntQuery(c.req.query('noteId'), 0); - if (noteId <= 0) return c.body(null, 400); + const noteId = parsePositiveId(c.req.query('noteId')); + if (noteId === null) return c.body(null, 400); const ankiConfig = getAnkiConnectConfig(); try { const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', { @@ -177,19 +175,14 @@ export function registerStatsIntegrationRoutes( app.post('/api/stats/anki/notesInfo', async (c) => { const body = await c.req.json().catch(() => null); - const noteIds: number[] = Array.isArray(body?.noteIds) - ? body.noteIds.filter( - (id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0, - ) - : []; + const noteIds = parsePositiveIdList(body?.noteIds); + if (!noteIds) return c.body(null, 400); if (noteIds.length === 0) return c.json(statsJson('ankiNotesInfo', [])); const resolvedNoteIds = Array.from( new Set( noteIds.map((noteId) => { const resolvedNoteId = options?.resolveAnkiNoteId?.(noteId); - return Number.isInteger(resolvedNoteId) && (resolvedNoteId as number) > 0 - ? (resolvedNoteId as number) - : noteId; + return isPositiveSafeInteger(resolvedNoteId) ? resolvedNoteId : noteId; }), ), ); diff --git a/src/core/services/stats-server/library-routes.ts b/src/core/services/stats-server/library-routes.ts index dd3e678a..00b9f464 100644 --- a/src/core/services/stats-server/library-routes.ts +++ b/src/core/services/stats-server/library-routes.ts @@ -8,12 +8,14 @@ import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; import { buildSentenceSearchOptions, enrichSessionsWithKnownWordMetrics, + isPositiveSafeInteger, + loadKnownWordsSet, parseBooleanQuery, parseDuplicateLineCleanupBody, parseExcludedWordsBody, parseIntQuery, + parsePositiveId, parsePositiveIdList, - loadKnownWordsSet, } from './route-support.js'; export function registerStatsLibraryRoutes( @@ -116,8 +118,8 @@ export function registerStatsLibraryRoutes( }); app.get('/api/stats/vocabulary/:wordId/detail', async (c) => { - const wordId = parseIntQuery(c.req.param('wordId'), 0); - if (wordId <= 0) return c.body(null, 400); + const wordId = parsePositiveId(c.req.param('wordId')); + if (wordId === null) return c.body(null, 400); const detail = await tracker.getWordDetail(wordId); if (!detail) return c.body(null, 404); const animeAppearances = await tracker.getWordAnimeAppearances(wordId); @@ -126,8 +128,8 @@ export function registerStatsLibraryRoutes( }); app.get('/api/stats/kanji/:kanjiId/detail', async (c) => { - const kanjiId = parseIntQuery(c.req.param('kanjiId'), 0); - if (kanjiId <= 0) return c.body(null, 400); + const kanjiId = parsePositiveId(c.req.param('kanjiId')); + if (kanjiId === null) return c.body(null, 400); const detail = await tracker.getKanjiDetail(kanjiId); if (!detail) return c.body(null, 404); const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId); @@ -141,8 +143,8 @@ export function registerStatsLibraryRoutes( }); app.get('/api/stats/media/:videoId', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.json(statsJson('error', null), 400); + const videoId = parsePositiveId(c.req.param('videoId')); + if (videoId === null) return c.json(statsJson('error', null), 400); const [detail, rawSessions, rollups] = await Promise.all([ tracker.getMediaDetail(videoId), tracker.getMediaSessions(videoId, 100), @@ -167,16 +169,16 @@ export function registerStatsLibraryRoutes( }); app.delete('/api/stats/anime/merge-recommendations/:recommendationId', async (c) => { - const recommendationId = parseIntQuery(c.req.param('recommendationId'), 0); - if (recommendationId <= 0) return c.body(null, 400); + const recommendationId = parsePositiveId(c.req.param('recommendationId')); + if (recommendationId === null) return c.body(null, 400); const dismissed = await tracker.dismissAnimeMergeRecommendation(recommendationId); if (!dismissed) return c.body(null, 404); return c.json(statsJson('dismissAnimeMergeRecommendation', { ok: true })); }); app.get('/api/stats/anime/:animeId', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) return c.body(null, 400); + const animeId = parsePositiveId(c.req.param('animeId')); + if (animeId === null) return c.body(null, 400); const detail = await tracker.getAnimeDetail(animeId); if (!detail) return c.body(null, 404); const [episodes, anilistEntries] = await Promise.all([ @@ -187,22 +189,22 @@ export function registerStatsLibraryRoutes( }); app.get('/api/stats/anime/:animeId/words', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); + const animeId = parsePositiveId(c.req.param('animeId')); const limit = parseIntQuery(c.req.query('limit'), 50, 200); - if (animeId <= 0) return c.body(null, 400); + if (animeId === null) return c.body(null, 400); return c.json(statsJson('animeWords', await tracker.getAnimeWords(animeId, limit))); }); app.get('/api/stats/anime/:animeId/rollups', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); + const animeId = parsePositiveId(c.req.param('animeId')); const limit = parseIntQuery(c.req.query('limit'), 90, 365); - if (animeId <= 0) return c.body(null, 400); + if (animeId === null) return c.body(null, 400); return c.json(statsJson('animeRollups', await tracker.getAnimeDailyRollups(animeId, limit))); }); app.patch('/api/stats/media/:videoId/watched', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.body(null, 400); + const videoId = parsePositiveId(c.req.param('videoId')); + if (videoId === null) return c.body(null, 400); const body = await c.req.json().catch(() => null); const watched = typeof body?.watched === 'boolean' ? body.watched : true; await tracker.setVideoWatched(videoId, watched); @@ -211,42 +213,40 @@ export function registerStatsLibraryRoutes( app.delete('/api/stats/sessions', async (c) => { const body = await c.req.json().catch(() => null); - const ids = Array.isArray(body?.sessionIds) - ? body.sessionIds.filter( - (id: unknown): id is number => Number.isSafeInteger(id) && (id as number) > 0, - ) - : []; - if (ids.length === 0) return c.body(null, 400); + const ids = parsePositiveIdList(body?.sessionIds); + if (!ids || ids.length === 0) return c.body(null, 400); await tracker.deleteSessions(ids); return c.json(statsJson('deleteSessions', { ok: true })); }); app.delete('/api/stats/sessions/:sessionId', async (c) => { - const sessionId = parseIntQuery(c.req.param('sessionId'), 0); - if (sessionId <= 0) return c.body(null, 400); + const sessionId = parsePositiveId(c.req.param('sessionId')); + if (sessionId === null) return c.body(null, 400); await tracker.deleteSession(sessionId); return c.json(statsJson('deleteSession', { ok: true })); }); app.delete('/api/stats/media/:videoId', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.body(null, 400); + const videoId = parsePositiveId(c.req.param('videoId')); + if (videoId === null) return c.body(null, 400); await tracker.deleteVideo(videoId); return c.json(statsJson('deleteVideo', { ok: true })); }); app.delete('/api/stats/anime/:animeId', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) return c.body(null, 400); + const animeId = parsePositiveId(c.req.param('animeId')); + if (animeId === null) return c.body(null, 400); await tracker.deleteAnime(animeId); return c.json(statsJson('deleteAnime', { ok: true })); }); app.post('/api/stats/anime/:animeId/merge', async (c) => { - const animeId = parseIntQuery(c.req.param('animeId'), 0); - if (animeId <= 0) return c.body(null, 400); + const animeId = parsePositiveId(c.req.param('animeId')); + if (animeId === null) return c.body(null, 400); const body = await c.req.json().catch(() => null); - const sourceAnimeIds = parsePositiveIdList(body?.sourceAnimeIds).filter((id) => id !== animeId); + const parsedSourceAnimeIds = parsePositiveIdList(body?.sourceAnimeIds); + if (!parsedSourceAnimeIds) return c.body(null, 400); + const sourceAnimeIds = parsedSourceAnimeIds.filter((id) => id !== animeId); if (sourceAnimeIds.length === 0) return c.body(null, 400); let summary: Awaited>; try { @@ -271,11 +271,11 @@ export function registerStatsLibraryRoutes( }); app.patch('/api/stats/media/:videoId/anime', async (c) => { - const videoId = parseIntQuery(c.req.param('videoId'), 0); - if (videoId <= 0) return c.body(null, 400); + const videoId = parsePositiveId(c.req.param('videoId')); + if (videoId === null) return c.body(null, 400); const body = await c.req.json().catch(() => null); - const animeId = Number.isSafeInteger(body?.animeId) ? (body.animeId as number) : 0; - if (animeId <= 0) return c.body(null, 400); + const animeId = body?.animeId; + if (!isPositiveSafeInteger(animeId)) return c.body(null, 400); try { const summary = await tracker.moveVideoToAnime(videoId, animeId); return c.json( diff --git a/src/core/services/stats-server/route-support.ts b/src/core/services/stats-server/route-support.ts index e50ef779..eda46cfc 100644 --- a/src/core/services/stats-server/route-support.ts +++ b/src/core/services/stats-server/route-support.ts @@ -48,6 +48,16 @@ export function parseIntQuery( return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit); } +export function isPositiveSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +} + +export function parsePositiveId(raw: string | undefined): number | null { + if (raw === undefined) return null; + const value = Number(raw); + return isPositiveSafeInteger(value) && String(value) === raw ? value : null; +} + export function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' { return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all' ? raw @@ -199,16 +209,16 @@ export async function enrichSessionsWithKnownWordMetrics< ); } -/** Deduplicated positive integer ids from an untrusted JSON body field. */ -export function parsePositiveIdList(raw: unknown): number[] { - if (!Array.isArray(raw)) return []; +/** Deduplicated positive safe integer ids from an untrusted JSON body field. */ +export function parsePositiveIdList(raw: unknown, maxItems?: number): number[] | null { + if (!Array.isArray(raw)) return null; const ids = new Set(); for (const value of raw) { - if (Number.isSafeInteger(value) && (value as number) > 0) { - ids.add(value as number); - } + if (!isPositiveSafeInteger(value)) return null; + ids.add(value); } - return [...ids]; + const parsed = [...ids]; + return maxItems === undefined ? parsed : parsed.slice(0, maxItems); } export function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean {