import { NextResponse } from "next/server" import { readFile } from "node:fs/promises" import { existsSync } from "node:fs" import { join } from "node:path" import { getManualsThumbnailsRoot } from "@/lib/manuals-paths" import { getManualAssetFromStorage } from "@/lib/manuals-object-storage" export const dynamic = "force-dynamic" function buildPlaceholderSvg(label: string) { const safeLabel = label .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") return ` Manual Preview Thumbnail unavailable ${safeLabel} `.trim() } function decodeSegments(pathArray: string[]) { return pathArray.map((segment) => { try { let decoded = segment while (decoded !== decodeURIComponent(decoded)) { decoded = decodeURIComponent(decoded) } return decoded } catch { return segment } }) } function invalidPath(pathValue: string) { return pathValue.includes("..") || pathValue.startsWith("/") } function getContentType(pathValue: string) { if (pathValue.toLowerCase().endsWith(".png")) { return "image/png" } if (pathValue.toLowerCase().endsWith(".webp")) { return "image/webp" } return "image/jpeg" } export async function GET( _request: Request, { params }: { params: Promise<{ path: string[] }> }, ) { try { const { path: pathArray } = await params const decodedPath = decodeSegments(pathArray) const filePath = decodedPath.join("/") const lowerPath = filePath.toLowerCase() if ( invalidPath(filePath) || (!lowerPath.endsWith(".jpg") && !lowerPath.endsWith(".jpeg") && !lowerPath.endsWith(".png") && !lowerPath.endsWith(".webp")) ) { return new NextResponse("Invalid path", { status: 400 }) } const storageObject = await getManualAssetFromStorage("thumbnails", filePath) if (storageObject) { return new NextResponse(Buffer.from(storageObject.body), { headers: { "Content-Type": storageObject.contentType || getContentType(filePath), "Content-Disposition": `inline; filename="${encodeURIComponent(decodedPath.at(-1) || "thumbnail.jpg")}"`, "Cache-Control": "public, max-age=31536000, immutable", "X-Content-Type-Options": "nosniff", }, }) } const thumbnailsDir = getManualsThumbnailsRoot() const fullPath = join(thumbnailsDir, filePath) const normalizedFullPath = fullPath.replace(/\\/g, "/") const normalizedThumbnailsDir = thumbnailsDir.replace(/\\/g, "/") if (!existsSync(normalizedFullPath) && !existsSync(fullPath)) { const label = decodedPath.at(-1)?.replace(/\.(jpg|jpeg|png|webp)$/i, "") || "Rocky Mountain Vending" return new NextResponse(buildPlaceholderSvg(label), { headers: { "Content-Type": "image/svg+xml; charset=utf-8", "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400", "X-Content-Type-Options": "nosniff", }, }) } const fileToRead = existsSync(normalizedFullPath) ? normalizedFullPath : fullPath const resolvedPath = fileToRead.replace(/\\/g, "/") if (!resolvedPath.startsWith(normalizedThumbnailsDir)) { return new NextResponse("Invalid path", { status: 400 }) } const fileBuffer = await readFile(fileToRead) return new NextResponse(fileBuffer, { headers: { "Content-Type": getContentType(filePath), "Content-Disposition": `inline; filename="${encodeURIComponent(decodedPath.at(-1) || "thumbnail.jpg")}"`, "Cache-Control": "public, max-age=31536000, immutable", "X-Content-Type-Options": "nosniff", }, }) } catch (error) { console.error("[manuals-api] failed to serve thumbnail", error) return new NextResponse("Internal server error", { status: 500 }) } }