141 lines
4.7 KiB
TypeScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'")
|
|
|
|
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 })
|
|
}
|
|
}
|