feat(storage): phase 5 — replace Supabase storage with local filesystem + HMAC evidence serving
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
|
||||||
import { getSession } from '@/lib/auth/get-session'
|
import { getSession } from '@/lib/auth/get-session'
|
||||||
import { validateIncidentInput, validateTypeDetails, type IncidentType, type MedicalStatus } from '@/lib/incidents/validate'
|
import { validateIncidentInput, validateTypeDetails, type IncidentType, type MedicalStatus } from '@/lib/incidents/validate'
|
||||||
import { uploadEvidenceFile } from '@/lib/supabase/storage'
|
import { uploadEvidenceFile } from '@/lib/storage/evidence'
|
||||||
import { sendNewIncidentEmail } from '@/lib/notifications/email'
|
import { sendNewIncidentEmail } from '@/lib/notifications/email'
|
||||||
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||||||
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
||||||
@@ -148,12 +147,11 @@ async function handlePost(request: Request) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Evidence upload — storage still uses supabase (Phase 5 replaces this)
|
// Evidence upload — local filesystem storage with HMAC-signed URLs
|
||||||
const supabase = await createClient()
|
|
||||||
const evidenceInserts: Array<typeof evidenceFiles.$inferInsert> = []
|
const evidenceInserts: Array<typeof evidenceFiles.$inferInsert> = []
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
const { publicUrl, hash } = await uploadEvidenceFile(supabase, file, incidentId, 'report', session.sub)
|
const { publicUrl, hash } = await uploadEvidenceFile(file, incidentId, 'report', session.sub)
|
||||||
evidenceInserts.push({
|
evidenceInserts.push({
|
||||||
incidentId,
|
incidentId,
|
||||||
stage: 'report',
|
stage: 'report',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { EvidenceStage } from '@/lib/supabase/storage'
|
import type { EvidenceStage } from '@/lib/storage/evidence'
|
||||||
|
|
||||||
type EvidenceFile = {
|
type EvidenceFile = {
|
||||||
id: string
|
id: string
|
||||||
@@ -18,12 +18,6 @@ interface Props {
|
|||||||
function isImage(type: string) { return type.startsWith('image/') }
|
function isImage(type: string) { return type.startsWith('image/') }
|
||||||
function isVideo(type: string) { return type.startsWith('video/') }
|
function isVideo(type: string) { return type.startsWith('video/') }
|
||||||
|
|
||||||
// Supabase Storage image transform endpoint. Falls back to the original object
|
|
||||||
// URL via onError if the project plan has no image transformation.
|
|
||||||
function thumbnailUrl(url: string, width = 320): string {
|
|
||||||
if (!url.includes('/storage/v1/object/public/')) return url
|
|
||||||
return `${url.replace('/storage/v1/object/public/', '/storage/v1/render/image/public/')}?width=${width}&quality=60`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EvidenceGallery({ files, stage }: Props) {
|
export function EvidenceGallery({ files, stage }: Props) {
|
||||||
const filtered = stage ? files.filter(f => f.stage === stage) : files
|
const filtered = stage ? files.filter(f => f.stage === stage) : files
|
||||||
@@ -41,14 +35,10 @@ export function EvidenceGallery({ files, stage }: Props) {
|
|||||||
>
|
>
|
||||||
{isImage(file.file_type) ? (
|
{isImage(file.file_type) ? (
|
||||||
<img
|
<img
|
||||||
src={thumbnailUrl(file.file_url)}
|
src={file.file_url}
|
||||||
alt="Evidence"
|
alt="Evidence"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
onError={e => {
|
|
||||||
const img = e.currentTarget
|
|
||||||
if (img.src !== file.file_url) img.src = file.file_url
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : isVideo(file.file_type) ? (
|
) : isVideo(file.file_type) ? (
|
||||||
<div className="w-full h-full flex items-center justify-center text-3xl">🎥</div>
|
<div className="w-full h-full flex items-center justify-center text-3xl">🎥</div>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { fileTypeFromBuffer } from 'file-type'
|
||||||
|
|
||||||
|
export type EvidenceStage = 'report' | 'response' | 'investigation' | 'capa' | 'verification'
|
||||||
|
|
||||||
|
const ALLOWED_MIME_TYPES = new Set([
|
||||||
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/heic', 'image/avif',
|
||||||
|
'video/mp4', 'video/quicktime', 'video/webm', 'video/x-msvideo',
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
])
|
||||||
|
|
||||||
|
function evidenceDir(): string {
|
||||||
|
const dir = process.env.EVIDENCE_DIR
|
||||||
|
if (!dir) throw new Error('EVIDENCE_DIR env var not set')
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmacSecret(): string {
|
||||||
|
const s = process.env.EVIDENCE_URL_SECRET
|
||||||
|
if (!s) throw new Error('EVIDENCE_URL_SECRET env var not set')
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signEvidencePath(filePath: string): string {
|
||||||
|
return crypto.createHmac('sha256', hmacSecret()).update(filePath).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEvidenceUrl(filePath: string): string {
|
||||||
|
const token = signEvidencePath(filePath)
|
||||||
|
return `/api/evidence/${filePath}?token=${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadEvidenceFile(
|
||||||
|
file: File,
|
||||||
|
incidentId: string,
|
||||||
|
stage: EvidenceStage,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ path: string; publicUrl: string; hash: string }> {
|
||||||
|
const isVideo = file.type.startsWith('video/')
|
||||||
|
const maxBytes = isVideo ? 200 * 1024 * 1024 : 10 * 1024 * 1024
|
||||||
|
if (file.size > maxBytes) {
|
||||||
|
throw new Error(`File too large: max ${isVideo ? '200MB' : '10MB'} for this file type`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await file.arrayBuffer()
|
||||||
|
const detected = await fileTypeFromBuffer(buffer)
|
||||||
|
const detectedMime = detected?.mime ?? null
|
||||||
|
if (!detectedMime || !ALLOWED_MIME_TYPES.has(detectedMime)) {
|
||||||
|
throw new Error(`File type not allowed: ${detectedMime ?? 'unknown'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = detected?.ext ?? file.name.split('.').pop() ?? 'bin'
|
||||||
|
const filename = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${ext}`
|
||||||
|
const filePath = `${userId}/${incidentId}/${stage}/${filename}`
|
||||||
|
|
||||||
|
const hash = await computeHashFromBuffer(buffer)
|
||||||
|
|
||||||
|
const fullPath = path.join(evidenceDir(), filePath)
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true })
|
||||||
|
fs.writeFileSync(fullPath, Buffer.from(buffer))
|
||||||
|
|
||||||
|
const publicUrl = getEvidenceUrl(filePath)
|
||||||
|
|
||||||
|
return { path: filePath, publicUrl, hash }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeHashFromBuffer(buffer: ArrayBuffer): Promise<string> {
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||||||
|
return Array.from(new Uint8Array(hashBuffer))
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user