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:
2026-07-23 22:14:54 +08:00
co-authored by Claude Sonnet 4.6
parent dacc1a5265
commit e5f74c367d
4 changed files with 169 additions and 17 deletions
+85
View File
@@ -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',
},
})
}
+3 -5
View File
@@ -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<typeof evidenceFiles.$inferInsert> = []
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',