feat: Add bookId validation to API routes and implement robust, length-limited filename generation with unit tests.
This commit is contained in:
parent
33d49d6966
commit
47d838039a
3 changed files with 88 additions and 1 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
await mkdir(DOCSTORE_DIR, { recursive: true });
|
||||
await mkdir(DOCUMENTS_V1_DIR, { recursive: true });
|
||||
|
|
@ -149,7 +163,8 @@ export async function ensureDocumentsV1Ready(): Promise<boolean> {
|
|||
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)) {
|
||||
|
|
|
|||
55
tests/docstore_unit.spec.ts
Normal file
55
tests/docstore_unit.spec.ts
Normal file
|
|
@ -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-');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue