fix(stats): reject malformed resource IDs before mutations (#259)

This commit is contained in:
2026-09-20 22:49:27 -07:00
committed by GitHub
parent 7959e1a4e5
commit f9c4e892dc
8 changed files with 300 additions and 105 deletions
@@ -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.
+4
View File
@@ -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
@@ -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<Response> =>
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(
+10 -31
View File
@@ -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<Record<keyof StatsCoverImagesRequest, unknown>>;
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<number>();
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<number, StatsCoverImagePayload> = {};
const media: Record<number, StatsCoverImagePayload> = {};
@@ -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);
@@ -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<string>();
@@ -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;
}),
),
);
@@ -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<ReturnType<typeof tracker.mergeAnime>>;
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(
@@ -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<number>();
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 {