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