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

118 lines
4.1 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { readFile } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import { getManualsThumbnailsRoot } from '@/lib/manuals-paths'
// API routes are not supported in static export (GHL hosting)
// Thumbnails are now served as static files from /thumbnails/
export const dynamic = 'force-static'
/**
* API route to serve thumbnail images for PDF manuals
* This allows serving files from outside the public folder
*
* Usage: /api/thumbnails/BevMax/manual.jpg
* NOTE: This route is disabled for static export. Use /thumbnails/ paths instead.
*/
// Required for static export - returns empty array to skip this route
export async function generateStaticParams(): Promise<Array<{ path: string[] }>> {
return []
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
try {
const { path: pathArray } = await params
// Decode URL-encoded path segments - handle multiple encodings
const decodedPath = pathArray.map(segment => {
try {
// Try decoding multiple times in case of double encoding
let decoded = segment
while (decoded !== decodeURIComponent(decoded)) {
decoded = decodeURIComponent(decoded)
}
return decoded
} catch {
// If decoding fails, use original
return segment
}
})
const filePath = decodedPath.join('/')
// Security: Prevent directory traversal
if (filePath.includes('..') || filePath.startsWith('/')) {
return new NextResponse('Invalid path', { status: 400 })
}
// Construct full path to thumbnail
const thumbnailsDir = getManualsThumbnailsRoot()
const fullPath = join(thumbnailsDir, filePath)
// Normalize paths to handle both forward and backward slashes
const normalizedFullPath = fullPath.replace(/\\/g, '/')
const normalizedThumbnailsDir = thumbnailsDir.replace(/\\/g, '/')
// Verify file exists and is within thumbnails directory
let fileToRead = normalizedFullPath
if (!existsSync(normalizedFullPath)) {
// Try with original path in case of encoding issues
if (existsSync(fullPath)) {
fileToRead = fullPath
} else {
// Try with different path separators
const altPath = fullPath.replace(/\//g, '\\')
if (existsSync(altPath)) {
fileToRead = altPath
} else {
// Thumbnail doesn't exist - return 404 (this is expected for manuals without thumbnails)
return new NextResponse('Thumbnail not found', { status: 404 })
}
}
}
// Verify the file is within the thumbnails directory (security check)
const resolvedPath = fileToRead.replace(/\\/g, '/')
if (!resolvedPath.startsWith(normalizedThumbnailsDir.replace(/\\/g, '/'))) {
console.error(`Security violation: Path outside thumbnails directory: ${resolvedPath}`)
return new NextResponse('Invalid path', { status: 400 })
}
// Only allow image files (jpg, jpeg, png)
const lowerPath = filePath.toLowerCase()
if (!lowerPath.endsWith('.jpg') && !lowerPath.endsWith('.jpeg') && !lowerPath.endsWith('.png')) {
return new NextResponse('Invalid file type', { status: 400 })
}
// Read and serve the file
const fileBuffer = await readFile(fileToRead)
// Get filename for Content-Disposition header
const filename = decodedPath[decodedPath.length - 1]
// Determine content type
const contentType = lowerPath.endsWith('.png')
? 'image/png'
: 'image/jpeg'
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': contentType,
'Content-Disposition': `inline; filename="${encodeURIComponent(filename)}"`,
'Cache-Control': 'public, max-age=31536000, immutable',
'X-Content-Type-Options': 'nosniff',
},
})
} catch (error) {
console.error('Error serving thumbnail:', error)
if (error instanceof Error) {
console.error('Error details:', error.message, error.stack)
}
return new NextResponse('Internal server error', { status: 500 })
}
}