Rocky_Mountain_Vending/app/api/thumbnails/[...path]/route.ts

141 lines
4.7 KiB
TypeScript

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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
return `
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="480" viewBox="0 0 800 480" fill="none">
<rect width="800" height="480" rx="36" fill="#F5F1E6"/>
<rect x="28" y="28" width="744" height="424" rx="28" fill="#FCFBF7" stroke="#DCC9A3" stroke-width="2"/>
<circle cx="126" cy="124" r="54" fill="#E5D5B3"/>
<path d="M102 124h48M126 100v48" stroke="#8A6730" stroke-width="10" stroke-linecap="round"/>
<text x="400" y="184" text-anchor="middle" fill="#5C4320" font-size="40" font-family="Arial, sans-serif" font-weight="700">
Manual Preview
</text>
<text x="400" y="236" text-anchor="middle" fill="#8A6730" font-size="24" font-family="Arial, sans-serif">
Thumbnail unavailable
</text>
<text x="400" y="298" text-anchor="middle" fill="#7A6A4A" font-size="22" font-family="Arial, sans-serif">
${safeLabel}
</text>
</svg>
`.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 })
}
}