187 lines
6.2 KiB
TypeScript
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 });
|
|
}
|
|
}
|