import path from 'path' import fs from 'fs' import sharp from 'sharp' import { NextRequest, NextResponse } from 'next/server' import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' import { requireAdmin, requireLibraryAccess } from '@/lib/auth' import { fileApiUrl, thumbnailApiUrl } from '@/lib/media-utils' const SCREENSHOT_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']) const MAX_SCREENSHOT_BYTES = 20 * 1024 * 1024 // 20 MB type GameDirResult = | { gameDir: string; screenshotsDir: string; folderPath: string } | { error: string; status: number } function getGameDir(libraryId: string, gameId: string): GameDirResult { const library = getLibrary(libraryId) if (!library) return { error: 'Library not found', status: 404 } if (library.type !== 'games') return { error: 'Library is not a games library', status: 400 } const libraryRoot = resolveLibraryRoot(library) const folderPath = decodeURIComponent(gameId) let gameDir: string try { gameDir = resolveAndJail(libraryRoot, folderPath) } catch { return { error: 'Invalid game path', status: 400 } } if (!fs.existsSync(gameDir)) return { error: 'Game folder not found', status: 404 } return { gameDir, screenshotsDir: path.join(gameDir, 'screenshots'), folderPath } } // ─── GET: list screenshots ──────────────────────────────────────────────────── export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl const libraryId = searchParams.get('libraryId') const gameId = searchParams.get('gameId') if (!libraryId || !gameId) { return NextResponse.json({ error: 'Missing libraryId or gameId' }, { status: 400 }) } const auth = await requireLibraryAccess(request, libraryId) if (auth instanceof NextResponse) return auth const resolved = getGameDir(libraryId, gameId) if ('error' in resolved) return NextResponse.json({ error: resolved.error }, { status: resolved.status }) const { screenshotsDir, folderPath } = resolved if (!fs.existsSync(screenshotsDir)) { return NextResponse.json({ screenshots: [] }) } let files: string[] try { files = fs.readdirSync(screenshotsDir) } catch { return NextResponse.json({ screenshots: [] }) } const screenshots = files .filter((f) => SCREENSHOT_EXTENSIONS.has(path.extname(f).toLowerCase())) .sort() .map((filename) => { const relPath = path.join(folderPath, 'screenshots', filename) return { filename, url: fileApiUrl(libraryId, relPath), thumbnailUrl: thumbnailApiUrl(libraryId, relPath), } }) return NextResponse.json({ screenshots }) } // ─── POST: upload screenshot ────────────────────────────────────────────────── export async function POST(request: NextRequest) { const auth = await requireAdmin(request) if (auth instanceof NextResponse) return auth const { searchParams } = request.nextUrl const libraryId = searchParams.get('libraryId') const gameId = searchParams.get('gameId') if (!libraryId || !gameId) { return NextResponse.json({ error: 'Missing libraryId or gameId' }, { status: 400 }) } const resolved = getGameDir(libraryId, gameId) if ('error' in resolved) return NextResponse.json({ error: resolved.error }, { status: resolved.status }) const { screenshotsDir, folderPath } = resolved let formData: FormData try { formData = await request.formData() } catch { return NextResponse.json({ error: 'Invalid form data' }, { status: 400 }) } const file = formData.get('screenshot') if (!(file instanceof File)) { return NextResponse.json({ error: 'screenshot field is required' }, { status: 400 }) } if (file.size > MAX_SCREENSHOT_BYTES) { return NextResponse.json({ error: 'File too large. Maximum size is 20 MB.' }, { status: 400 }) } const rawBuffer = Buffer.from(await file.arrayBuffer()) let processedBuffer: Buffer try { processedBuffer = await sharp(rawBuffer).jpeg({ quality: 90 }).toBuffer() } catch { return NextResponse.json({ error: 'Invalid or corrupt image file.' }, { status: 400 }) } fs.mkdirSync(screenshotsDir, { recursive: true }) const filename = `shot-${Date.now()}.jpg` fs.writeFileSync(path.join(screenshotsDir, filename), processedBuffer) const relPath = path.join(folderPath, 'screenshots', filename) return NextResponse.json( { filename, url: fileApiUrl(libraryId, relPath), thumbnailUrl: thumbnailApiUrl(libraryId, relPath), }, { status: 201 } ) } // ─── DELETE: remove screenshot ──────────────────────────────────────────────── export async function DELETE(request: NextRequest) { const auth = await requireAdmin(request) if (auth instanceof NextResponse) return auth const { searchParams } = request.nextUrl const libraryId = searchParams.get('libraryId') const gameId = searchParams.get('gameId') const filename = searchParams.get('filename') if (!libraryId || !gameId || !filename) { return NextResponse.json({ error: 'Missing libraryId, gameId, or filename' }, { status: 400 }) } // Filename must be a plain basename — no path separators, no traversal if (filename !== path.basename(filename) || filename.includes('..')) { return NextResponse.json({ error: 'Invalid filename' }, { status: 400 }) } const resolved = getGameDir(libraryId, gameId) if ('error' in resolved) return NextResponse.json({ error: resolved.error }, { status: resolved.status }) const { screenshotsDir } = resolved let filePath: string try { filePath = resolveAndJail(screenshotsDir, filename) } catch { return NextResponse.json({ error: 'Invalid filename' }, { status: 400 }) } try { fs.unlinkSync(filePath) } catch { return NextResponse.json({ error: 'File not found or could not be deleted' }, { status: 404 }) } return new NextResponse(null, { status: 204 }) }