import { NextRequest, NextResponse } from 'next/server' import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' import { getSession } from '@/lib/auth/get-session' import { signEvidencePath } from '@/lib/storage/evidence' import { asAdmin } from '@/lib/db/with-user' import { evidenceFiles } from '@/lib/db/schema' import { eq } from 'drizzle-orm' export const dynamic = 'force-dynamic' export async function GET( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { const { path: segments } = await params const filePath = segments.join('/') // Verify HMAC token const token = request.nextUrl.searchParams.get('token') if (!token) return new NextResponse('Missing token', { status: 401 }) if (token.length !== 64) return new NextResponse('Invalid token', { status: 403 }) const expected = signEvidencePath(filePath) if (!crypto.timingSafeEqual(Buffer.from(token, 'hex'), Buffer.from(expected, 'hex'))) { return new NextResponse('Invalid token', { status: 403 }) } // Verify session const session = await getSession() if (!session) return new NextResponse('Unauthorized', { status: 401 }) // Authorization: uploader or hse/admin const isPrivileged = ['hse', 'admin'].includes(session.role) if (!isPrivileged) { // Check if user is the uploader const [row] = await asAdmin(db => db.select({ uploadedBy: evidenceFiles.uploadedBy }) .from(evidenceFiles) .where(eq(evidenceFiles.fileUrl, `/api/evidence/${filePath}`)) .limit(1) ) if (!row || row.uploadedBy !== session.sub) { return new NextResponse('Forbidden', { status: 403 }) } } // Serve file — path traversal prevention const evidenceDir = process.env.EVIDENCE_DIR if (!evidenceDir) return new NextResponse('Storage not configured', { status: 500 }) const resolved = path.resolve(path.join(evidenceDir, filePath)) if (!resolved.startsWith(path.resolve(evidenceDir))) { return new NextResponse('Forbidden', { status: 403 }) } if (!fs.existsSync(resolved)) return new NextResponse('Not found', { status: 404 }) const stat = fs.statSync(resolved) const fileBuffer = fs.readFileSync(resolved) // Infer content type from extension const ext = path.extname(filePath).toLowerCase().slice(1) const MIME: Record = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', heic: 'image/heic', avif: 'image/avif', mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', pdf: 'application/pdf', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', } const contentType = MIME[ext] ?? 'application/octet-stream' return new NextResponse(fileBuffer, { status: 200, headers: { 'Content-Type': contentType, 'Content-Length': String(stat.size), 'Cache-Control': 'private, max-age=3600', 'Content-Disposition': 'inline', 'X-Content-Type-Options': 'nosniff', }, }) }