97 lines
3.2 KiB
TypeScript
97 lines
3.2 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 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)) {
|
|
return new NextResponse("Thumbnail not found", { status: 404 })
|
|
}
|
|
|
|
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 })
|
|
}
|
|
}
|