import { NextRequest, NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; import sharp from 'sharp'; const STORAGE_PATH = process.env.MEDIA_STORAGE_PATH ? path.resolve(process.env.MEDIA_STORAGE_PATH) : path.resolve(process.cwd(), '../storage/media'); const CACHE_PATH = path.join(STORAGE_PATH, 'cache'); const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=31536000, immutable', }; interface MediaMeta { mimeType: string; type: 'image' | 'video'; size: number; } function readMeta(id: string): MediaMeta | null { const metaPath = path.join(STORAGE_PATH, `${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( filePath: string, width: number, meta: MediaMeta, id: string ): Promise { fs.mkdirSync(CACHE_PATH, { recursive: true }); const cacheKey = `${id}_w${width}`; const cachedPath = path.join(CACHE_PATH, cacheKey); if (fileExists(cachedPath)) { const cached = fs.readFileSync(cachedPath); 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(cachedPath, 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 async function GET( request: NextRequest, { params }: { params: { id: string } } ) { const { id } = params; const filePath = path.join(STORAGE_PATH, id); if (!fileExists(filePath)) { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } const meta = readMeta(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(filePath, width, meta, id); } if (meta.type === 'video') { const rangeHeader = request.headers.get('range'); return handleVideoStream(filePath, meta, rangeHeader); } // Full image, no resize 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, }, }); }