86 lines
3.1 KiB
TypeScript
86 lines
3.1 KiB
TypeScript
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<string, string> = {
|
|
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',
|
|
},
|
|
})
|
|
}
|