import { NextRequest, NextResponse } from 'next/server' import fs from 'fs' import path from 'path' import archiver from 'archiver' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' import { requireLibraryAccess } from '@/lib/auth' const MIME_TYPES: Record = { '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo', '.webm': 'video/webm', '.m4v': 'video/x-m4v', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp', '.tiff': 'image/tiff', '.tif': 'image/tiff', '.zip': 'application/zip', '.dmg': 'application/x-apple-diskimage', '.gz': 'application/gzip', } function getMimeType(filePath: string): string { // Special-case .tar.gz before checking the last extension if (filePath.toLowerCase().endsWith('.tar.gz')) return 'application/gzip' const ext = path.extname(filePath).toLowerCase() return MIME_TYPES[ext] ?? 'application/octet-stream' } function isDownloadAttachment(filePath: string): boolean { const lower = filePath.toLowerCase() return lower.endsWith('.zip') || lower.endsWith('.tar.gz') || lower.endsWith('.dmg') } export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl const libraryId = searchParams.get('libraryId') const subpath = searchParams.get('path') if (!libraryId || !subpath) { return NextResponse.json({ error: 'Missing libraryId or path' }, { status: 400 }) } const auth = await requireLibraryAccess(request, libraryId) if (auth instanceof NextResponse) return auth const library = getLibrary(libraryId) if (!library) { return NextResponse.json({ error: 'Library not found' }, { status: 404 }) } const root = resolveLibraryRoot(library) let filePath: string try { filePath = resolveAndJail(root, subpath) } catch { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } let stat: fs.Stats try { stat = fs.statSync(filePath) } catch { return NextResponse.json({ error: 'File not found' }, { status: 404 }) } // .app bundle: stream the directory as a zip archive on the fly if (stat.isDirectory() && subpath.toLowerCase().endsWith('.app')) { const bundleName = path.basename(filePath) const zipName = `${bundleName}.zip` const archive = archiver('zip', { zlib: { level: 6 } }) archive.directory(filePath, bundleName) archive.finalize() return new NextResponse(archive as unknown as ReadableStream, { status: 200, headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${encodeURIComponent(zipName)}"`, 'Cache-Control': 'no-store', }, }) } if (!stat.isFile()) { return NextResponse.json({ error: 'Not a file' }, { status: 400 }) } const mimeType = getMimeType(filePath) const fileSize = stat.size const rangeHeader = request.headers.get('range') const contentDisposition = isDownloadAttachment(filePath) ? `attachment; filename="${encodeURIComponent(path.basename(filePath))}"` : `inline; filename="${encodeURIComponent(path.basename(filePath))}"` if (rangeHeader) { // Parse "bytes=start-end" const match = rangeHeader.match(/bytes=(\d*)-(\d*)/) if (!match) { return new NextResponse('Invalid Range', { status: 416 }) } const start = match[1] ? parseInt(match[1], 10) : 0 const end = match[2] ? parseInt(match[2], 10) : fileSize - 1 if (start > end || end >= fileSize) { return new NextResponse('Range Not Satisfiable', { status: 416, headers: { 'Content-Range': `bytes */${fileSize}` }, }) } const chunkSize = end - start + 1 const stream = fs.createReadStream(filePath, { start, end }) return new NextResponse(stream as unknown as ReadableStream, { status: 206, headers: { 'Content-Type': mimeType, 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Content-Length': String(chunkSize), 'Accept-Ranges': 'bytes', 'Content-Disposition': contentDisposition, 'Cache-Control': 'public, max-age=3600', }, }) } // Full file response const stream = fs.createReadStream(filePath) return new NextResponse(stream as unknown as ReadableStream, { status: 200, headers: { 'Content-Type': mimeType, 'Content-Length': String(fileSize), 'Accept-Ranges': 'bytes', 'Content-Disposition': contentDisposition, 'Cache-Control': 'public, max-age=3600', }, }) }