import { NextRequest, NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; import sharp from 'sharp'; /** Resolve at request time so systemd / production env is visible (avoid build-time inlining). */ function getMediaStorageRoot(): string { const envPath = process.env['MEDIA_STORAGE_PATH']; if (envPath) { return path.resolve(envPath); } const cwd = process.cwd(); const candidates = [ path.resolve(cwd, '../backend/storage/media'), path.resolve(cwd, '../storage/media'), ]; for (const dir of candidates) { if (fs.existsSync(dir)) { return dir; } } return candidates[0]; } const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=31536000, immutable', }; interface MediaMeta { mimeType: string; type: 'image' | 'video'; size: number; } function readMeta(root: string, id: string): MediaMeta | null { const metaPath = path.join(root, `${id}.json`); try { const raw = fs.readFileSync(metaPath, 'utf-8'); return JSON.parse(raw); } catch { return null; } } function fileExists(filePath: string): boolean { try { fs.accessSync(filePath, fs.constants.R_OK); return true; } catch { return false; } } async function handleImageResize( root: string, filePath: string, width: number, meta: MediaMeta, id: string ): Promise { const cachePath = path.join(root, 'cache'); fs.mkdirSync(cachePath, { recursive: true }); const cacheKey = `${id}_w${width}`; const cachedFile = path.join(cachePath, cacheKey); if (fileExists(cachedFile)) { const cached = fs.readFileSync(cachedFile); return new NextResponse(new Uint8Array(cached), { status: 200, headers: { 'Content-Type': meta.mimeType, 'Content-Length': String(cached.length), ...CACHE_HEADERS, }, }); } const buffer = fs.readFileSync(filePath); const resized = await sharp(buffer) .resize({ width, withoutEnlargement: true }) .toBuffer(); fs.writeFileSync(cachedFile, resized); return new NextResponse(new Uint8Array(resized), { status: 200, headers: { 'Content-Type': meta.mimeType, 'Content-Length': String(resized.length), ...CACHE_HEADERS, }, }); } function handleVideoStream( filePath: string, meta: MediaMeta, rangeHeader: string | null ): NextResponse { const stat = fs.statSync(filePath); const fileSize = stat.size; if (rangeHeader) { const parts = rangeHeader.replace(/bytes=/, '').split('-'); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; const chunkSize = end - start + 1; const stream = fs.createReadStream(filePath, { start, end }); const readable = new ReadableStream({ start(controller) { stream.on('data', (chunk: string | Buffer) => controller.enqueue(chunk as Buffer)); stream.on('end', () => controller.close()); stream.on('error', (err) => controller.error(err)); }, }); return new NextResponse(readable as any, { status: 206, headers: { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': String(chunkSize), 'Content-Type': meta.mimeType, ...CACHE_HEADERS, }, }); } const stream = fs.createReadStream(filePath); const readable = new ReadableStream({ start(controller) { stream.on('data', (chunk: string | Buffer) => controller.enqueue(chunk as Buffer)); stream.on('end', () => controller.close()); stream.on('error', (err) => controller.error(err)); }, }); return new NextResponse(readable as any, { status: 200, headers: { 'Accept-Ranges': 'bytes', 'Content-Length': String(fileSize), 'Content-Type': meta.mimeType, ...CACHE_HEADERS, }, }); } export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; export async function GET( request: NextRequest, context: { params: Promise<{ id: string }> | { id: string } } ) { const params = await Promise.resolve(context.params); const id = params.id; if (!id) { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } const root = getMediaStorageRoot(); const filePath = path.join(root, id); if (!fileExists(filePath)) { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } const meta = readMeta(root, id); if (!meta) { return NextResponse.json({ error: 'Metadata not found' }, { status: 404 }); } const { searchParams } = new URL(request.url); const widthParam = searchParams.get('w'); if (meta.type === 'image' && widthParam) { const width = parseInt(widthParam, 10); if (isNaN(width) || width < 1 || width > 4096) { return NextResponse.json({ error: 'Invalid width' }, { status: 400 }); } return handleImageResize(root, filePath, width, meta, id); } if (meta.type === 'video') { const rangeHeader = request.headers.get('range'); return handleVideoStream(filePath, meta, rangeHeader); } const buffer = fs.readFileSync(filePath); return new NextResponse(new Uint8Array(buffer), { status: 200, headers: { 'Content-Type': meta.mimeType, 'Content-Length': String(buffer.length), ...CACHE_HEADERS, }, }); }