From e5f74c367d6501f91591dd174fd3bd590e1764f9 Mon Sep 17 00:00:00 2001 From: weeihan Date: Thu, 23 Jul 2026 22:14:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(storage):=20phase=205=20=E2=80=94=20replac?= =?UTF-8?q?e=20Supabase=20storage=20with=20local=20filesystem=20+=20HMAC?= =?UTF-8?q?=20evidence=20serving?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/api/evidence/[...path]/route.ts | 85 +++++++++++++++++++++++ app/api/incidents/route.ts | 8 +-- components/incidents/evidence-gallery.tsx | 14 +--- lib/storage/evidence.ts | 79 +++++++++++++++++++++ 4 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 app/api/evidence/[...path]/route.ts create mode 100644 lib/storage/evidence.ts diff --git a/app/api/evidence/[...path]/route.ts b/app/api/evidence/[...path]/route.ts new file mode 100644 index 0000000..f9a5e87 --- /dev/null +++ b/app/api/evidence/[...path]/route.ts @@ -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 = { + 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', + }, + }) +} diff --git a/app/api/incidents/route.ts b/app/api/incidents/route.ts index 9fc9fc0..2ac030e 100644 --- a/app/api/incidents/route.ts +++ b/app/api/incidents/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' import { getSession } from '@/lib/auth/get-session' 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 { sendWhatsAppMessage } from '@/lib/notifications/whatsapp' 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) - const supabase = await createClient() + // Evidence upload — local filesystem storage with HMAC-signed URLs const evidenceInserts: Array = [] for (const file of files) { try { - const { publicUrl, hash } = await uploadEvidenceFile(supabase, file, incidentId, 'report', session.sub) + const { publicUrl, hash } = await uploadEvidenceFile(file, incidentId, 'report', session.sub) evidenceInserts.push({ incidentId, stage: 'report', diff --git a/components/incidents/evidence-gallery.tsx b/components/incidents/evidence-gallery.tsx index ab65b25..cb984f4 100644 --- a/components/incidents/evidence-gallery.tsx +++ b/components/incidents/evidence-gallery.tsx @@ -1,6 +1,6 @@ 'use client' -import type { EvidenceStage } from '@/lib/supabase/storage' +import type { EvidenceStage } from '@/lib/storage/evidence' type EvidenceFile = { id: string @@ -18,12 +18,6 @@ interface Props { function isImage(type: string) { return type.startsWith('image/') } 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) { const filtered = stage ? files.filter(f => f.stage === stage) : files @@ -41,14 +35,10 @@ export function EvidenceGallery({ files, stage }: Props) { > {isImage(file.file_type) ? ( Evidence { - const img = e.currentTarget - if (img.src !== file.file_url) img.src = file.file_url - }} /> ) : isVideo(file.file_type) ? (
🎥
diff --git a/lib/storage/evidence.ts b/lib/storage/evidence.ts new file mode 100644 index 0000000..aae8607 --- /dev/null +++ b/lib/storage/evidence.ts @@ -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 { + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer) + return Array.from(new Uint8Array(hashBuffer)) + .map(b => b.toString(16).padStart(2, '0')) + .join('') +}