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
@@ -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 {