Files
openmaic/OpenMAIC/app/api/classroom-media/[classroomId]/[...path]/route.ts
2026-08-16 14:58:47 +08:00

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 });
}
}