From 47d838039af04f17e942f7f81a95af29d7a7d59e Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 19 Jan 2026 17:04:02 -0700 Subject: [PATCH] feat: Add `bookId` validation to API routes and implement robust, length-limited filename generation with unit tests. --- src/app/api/audiobook/route.ts | 17 +++++++++++ src/lib/server/docstore.ts | 17 ++++++++++- tests/docstore_unit.spec.ts | 55 ++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/docstore_unit.spec.ts diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 8c64e44..d2e639d 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -140,6 +140,12 @@ function buildAtempoFilter(speed: number): string { return `atempo=2.0,atempo=${second.toFixed(3)}`; } +const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; + +function isSafeId(value: string): boolean { + return SAFE_ID_REGEX.test(value); +} + export async function POST(request: NextRequest) { try { // Parse the request body @@ -155,6 +161,11 @@ export async function POST(request: NextRequest) { // Generate or use existing book ID const bookId = data.bookId || randomUUID(); + + if (!isSafeId(bookId)) { + return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); + } + const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); // Create intermediate directory @@ -324,6 +335,9 @@ export async function GET(request: NextRequest) { if (!bookId) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } + if (!isSafeId(bookId)) { + return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); + } if (!(await isAudiobooksV1Ready())) { return NextResponse.json( @@ -514,6 +528,9 @@ export async function DELETE(request: NextRequest) { if (!bookId) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } + if (!isSafeId(bookId)) { + return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); + } if (!(await isAudiobooksV1Ready())) { return NextResponse.json( diff --git a/src/lib/server/docstore.ts b/src/lib/server/docstore.ts index 18c3a5f..54910e7 100644 --- a/src/lib/server/docstore.ts +++ b/src/lib/server/docstore.ts @@ -101,6 +101,20 @@ function safeDocumentName(rawName: string, fallback: string): string { return baseName.replaceAll('\u0000', '').slice(0, 240) || fallback; } +export function getMigratedDocumentFileName(id: string, name: string): string { + const prefix = `${id}__`; + const encodedName = encodeURIComponent(name); + let targetFileName = `${prefix}${encodedName}`; + + // Ensure total filename length is within safe limits (e.g. 240 chars). + // If too long, use a deterministic hash of the name instead of the full encoded name. + if (targetFileName.length > 240) { + const nameHash = createHash('sha256').update(name).digest('hex').slice(0, 32); + targetFileName = `${prefix}truncated-${nameHash}`; + } + return targetFileName; +} + export async function ensureDocumentsV1Ready(): Promise { await mkdir(DOCSTORE_DIR, { recursive: true }); await mkdir(DOCUMENTS_V1_DIR, { recursive: true }); @@ -149,7 +163,8 @@ export async function ensureDocumentsV1Ready(): Promise { const id = createHash('sha256').update(content).digest('hex'); const fallbackName = `${id}.${metadata.type}`; const name = safeDocumentName(metadata.name, fallbackName); - const targetFileName = `${id}__${encodeURIComponent(name)}`; + + const targetFileName = getMigratedDocumentFileName(id, name); const targetPath = path.join(DOCUMENTS_V1_DIR, targetFileName); if (!existsSync(targetPath)) { diff --git a/tests/docstore_unit.spec.ts b/tests/docstore_unit.spec.ts new file mode 100644 index 0000000..fda3d92 --- /dev/null +++ b/tests/docstore_unit.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test'; +import { getMigratedDocumentFileName } from '@/lib/server/docstore'; + +test.describe('Docstore Filename Safety', () => { + const id = 'a'.repeat(64); // Simulate sha256 hex ID + + test('should generate standard filename for short names', () => { + const name = 'test-file.pdf'; + const result = getMigratedDocumentFileName(id, name); + expect(result).toBe(`${id}__test-file.pdf`); + expect(result.length).toBeLessThanOrEqual(240); + }); + + test('should truncate very long names', () => { + const longName = 'a'.repeat(300); + const result = getMigratedDocumentFileName(id, longName); + + expect(result.length).toBeLessThanOrEqual(240); + expect(result).toContain('truncated-'); + expect(result.startsWith(`${id}__`)).toBeTruthy(); + }); + + test('should truncate names that become too long after encoding', () => { + // Chinese characters take 9 chars each when encoded (%XX%XX%XX) + const specialName = '特殊字符'.repeat(30); + const result = getMigratedDocumentFileName(id, specialName); + + expect(result.length).toBeLessThanOrEqual(240); + if (result.includes('truncated-')) { + expect(result).toContain('truncated-'); + } else { + // If it fits (depends on length), just ensure it is valid + // 30 * 4 = 120 chars raw, but encoded? + // Let's ensure logic works. + } + }); + + test('should handle edge case length exactly', () => { + // Create a name that would result in exactly 241 chars to trigger truncation + // Prefix is 64 + 2 = 66 chars. + // Max allowed = 240. + // Available for name = 240 - 66 = 174. + // If we give 175 chars, it should truncate. + const name = 'a'.repeat(175); + const result = getMigratedDocumentFileName(id, name); + expect(result).toContain('truncated-'); + }); + + test('should not truncate if exactly at limit', () => { + const name = 'a'.repeat(174); + const result = getMigratedDocumentFileName(id, name); + expect(result.length).toBe(240); + expect(result).not.toContain('truncated-'); + }); +});