Files
Nele-Till-Hochzeit/src/app/api/media/[...path]/route.ts
T

187 lines
6.2 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { validateMediaPath } from '@/lib/security/path';
import { validateSession } from '@/lib/auth/session';
import { getSafeContentType } from '@/lib/media/types';
import { getThumbnailPath, getGalleryPath } from '@/lib/processing/images';
import { getVideoPosterPath, getVideoPreviewPath } from '@/lib/processing/videos';
import path from 'path';
import fs from 'fs';
const MEDIA_ROOT = process.env.MEDIA_ROOT || '/media';
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
try {
const relativePath = params.path.join('/');
const searchParams = request.nextUrl.searchParams;
const type = searchParams.get('type') || 'original';
// Validate path security — prevent traversal, null bytes, symlink escapes
const validatedPath = validateMediaPath(relativePath, MEDIA_ROOT);
if (!validatedPath) {
console.warn(`[Security] Blocked path traversal attempt: ${relativePath}`);
return new NextResponse('Invalid path', { status: 400 });
}
// Check publication status in database
const mediaItem = await prisma.mediaItem.findFirst({
where: { relativePath },
});
if (!mediaItem) {
return new NextResponse('Not found', { status: 404 });
}
// CRITICAL: Unpublished media returns 403 for public, but allows admin
// This ensures admins can see previews of newly uploaded or deactivated photos in the dashboard.
if (!mediaItem.published) {
const isAuthenticated = await validateSession(request);
if (!isAuthenticated) {
return new NextResponse('Forbidden', { status: 403 });
}
}
// Determine which file to serve
let filePath: string;
let contentTypeExt: string;
switch (type) {
case 'thumb':
case 'gallery':
if (mediaItem.mediaType === 'VIDEO') {
filePath = getVideoPosterPath(relativePath);
} else {
filePath = type === 'gallery' ? getGalleryPath(relativePath) : getThumbnailPath(relativePath);
}
contentTypeExt = '.webp';
break;
case 'poster':
filePath = getVideoPosterPath(relativePath);
contentTypeExt = '.webp';
break;
case 'preview':
filePath = getVideoPreviewPath(relativePath);
contentTypeExt = '.mp4';
break;
case 'original':
default:
filePath = validatedPath;
contentTypeExt = path.extname(relativePath);
break;
}
// Check file exists
try {
await fs.promises.access(filePath, fs.constants.R_OK);
} catch {
// Cache asset might not be generated yet — attempt on-demand generation
try {
if (mediaItem.mediaType === 'VIDEO' && (type === 'poster' || type === 'thumb' || type === 'gallery')) {
const { processVideo } = await import('@/lib/processing/videos');
const dims = await processVideo(relativePath);
if (dims) {
await prisma.mediaItem.update({
where: { relativePath },
data: {
width: dims.width,
height: dims.height,
duration: dims.duration,
previewReady: true,
thumbnailReady: true,
},
});
}
} else if (mediaItem.mediaType === 'IMAGE' && (type === 'thumb' || type === 'gallery')) {
const { processImage } = await import('@/lib/processing/images');
const dims = await processImage(relativePath);
if (dims) {
await prisma.mediaItem.update({
where: { relativePath },
data: {
width: dims.width,
height: dims.height,
thumbnailReady: true,
},
});
}
}
} catch (genErr) {
console.error(`[Media] On-demand processing failed for ${relativePath}:`, genErr);
}
try {
await fs.promises.access(filePath, fs.constants.R_OK);
} catch {
return new NextResponse('Not found', { status: 404 });
}
}
// Get file stats for Content-Length
const stat = await fs.promises.stat(filePath);
const fileSize = stat.size;
// Handle Range Requests (CRITICAL for video streaming in Safari/Chrome)
const rangeHeader = request.headers.get('range');
let start = 0;
let end = fileSize - 1;
let isPartial = false;
if (rangeHeader) {
const parts = rangeHeader.replace(/bytes=/, '').split('-');
const partialStart = parts[0];
const partialEnd = parts[1];
start = parseInt(partialStart, 10);
end = partialEnd ? parseInt(partialEnd, 10) : fileSize - 1;
if (start >= fileSize) {
return new NextResponse('Requested range not satisfiable', { status: 416 });
}
isPartial = true;
}
const contentLength = (end - start) + 1;
// Build response headers
const contentType = getSafeContentType(contentTypeExt);
const headers = new Headers();
headers.set('Content-Type', contentType);
headers.set('Content-Length', contentLength.toString());
headers.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
headers.set('Accept-Ranges', 'bytes');
if (isPartial) {
headers.set('Content-Range', `bytes ${start}-${end}/${fileSize}`);
}
// SVG: force download to prevent XSS
if (contentType === 'image/svg+xml') {
headers.set('Content-Disposition', `attachment; filename="${path.basename(relativePath)}"`);
}
// Stream the file chunk
const fileStream = fs.createReadStream(filePath, { start, end });
const webStream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk as Buffer)));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => controller.error(err));
},
cancel() {
fileStream.destroy();
},
});
return new NextResponse(webStream, {
status: isPartial ? 206 : 200,
headers
});
} catch (error) {
console.error('[MediaServe] Error:', error);
return new NextResponse('Internal server error', { status: 500 });
}
}