- New /api/game-screenshots route handles GET (list), POST (upload), and DELETE (remove single file)
- Screenshots stored in a screenshots/ subfolder inside each game directory
- Images converted to JPEG via Sharp on upload, named shot-{timestamp}.jpg
- Modal shows a horizontal scrollable strip of 16:9 thumbnail tiles
- Hover a tile to reveal a delete button; uploading placeholders appear per in-flight upload
- Dashed + tile triggers multi-file picker for batch uploads
- Click any thumbnail to open a full-screen lightbox with prev/next arrows, counter, and keyboard nav (←/→/Esc)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
178 lines
6.1 KiB
TypeScript
178 lines
6.1 KiB
TypeScript
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 })
|
|
}
|