101 lines
3.3 KiB
TypeScript
101 lines
3.3 KiB
TypeScript
import { promises as fs, createReadStream } from 'fs';
|
|
import path from 'path';
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { CLASSROOMS_DIR, isValidClassroomId } from '@/lib/server/classroom-storage';
|
|
import { createLogger } from '@/lib/logger';
|
|
import { mayAccessRawClassroom } from '@/lib/server/published-classroom-access';
|
|
|
|
const log = createLogger('ClassroomMedia');
|
|
|
|
const MIME_TYPES: Record<string, string> = {
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.webp': 'image/webp',
|
|
'.gif': 'image/gif',
|
|
'.mp4': 'video/mp4',
|
|
'.webm': 'video/webm',
|
|
'.mp3': 'audio/mpeg',
|
|
'.wav': 'audio/wav',
|
|
'.ogg': 'audio/ogg',
|
|
'.aac': 'audio/aac',
|
|
};
|
|
|
|
export async function GET(
|
|
_req: NextRequest,
|
|
{ params }: { params: Promise<{ classroomId: string; path: string[] }> },
|
|
) {
|
|
const { classroomId, path: pathSegments } = await params;
|
|
|
|
// Validate classroomId
|
|
if (!isValidClassroomId(classroomId)) {
|
|
return NextResponse.json({ error: 'Invalid classroom ID' }, { status: 400 });
|
|
}
|
|
|
|
if (!(await mayAccessRawClassroom(classroomId))) {
|
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
}
|
|
|
|
// Validate path segments — no traversal
|
|
const joined = pathSegments.join('/');
|
|
if (joined.includes('..') || pathSegments.some((s) => s.includes('\0'))) {
|
|
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
}
|
|
|
|
// Only allow media/ and audio/ subdirectories
|
|
const subDir = pathSegments[0];
|
|
if (subDir !== 'media' && subDir !== 'audio') {
|
|
return NextResponse.json({ error: 'Invalid path' }, { status: 404 });
|
|
}
|
|
|
|
const filePath = path.join(CLASSROOMS_DIR, classroomId, ...pathSegments);
|
|
const resolvedBase = path.resolve(CLASSROOMS_DIR, classroomId);
|
|
|
|
try {
|
|
// Resolve symlinks and verify the real path stays within the classroom dir
|
|
const realPath = await fs.realpath(filePath);
|
|
if (!realPath.startsWith(resolvedBase + path.sep) && realPath !== resolvedBase) {
|
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
}
|
|
|
|
const stat = await fs.stat(realPath);
|
|
if (!stat.isFile()) {
|
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
}
|
|
|
|
const ext = path.extname(realPath).toLowerCase();
|
|
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
|
|
// Stream the file to avoid loading large videos into memory
|
|
const stream = createReadStream(realPath);
|
|
const webStream = new ReadableStream({
|
|
start(controller) {
|
|
stream.on('data', (chunk: Buffer | string) => controller.enqueue(chunk));
|
|
stream.on('end', () => controller.close());
|
|
stream.on('error', (err) => controller.error(err));
|
|
},
|
|
cancel() {
|
|
stream.destroy();
|
|
},
|
|
});
|
|
|
|
return new NextResponse(webStream, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': contentType,
|
|
'Content-Length': String(stat.size),
|
|
'Cache-Control': 'public, max-age=86400, immutable',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
}
|
|
log.error(
|
|
`Classroom media serving failed [classroomId=${classroomId}, path=${joined}]:`,
|
|
error,
|
|
);
|
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
|
}
|
|
}
|