mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
fix(stats): reject malformed resource IDs before mutations (#259)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user