diff --git a/components/incidents/file-upload.tsx b/components/incidents/file-upload.tsx index 9d25312..355de38 100644 --- a/components/incidents/file-upload.tsx +++ b/components/incidents/file-upload.tsx @@ -1,7 +1,6 @@ 'use client' import { useRef, useState } from 'react' -import type { EvidenceStage } from '@/lib/supabase/storage' const ACCEPTED = [ 'image/jpeg', 'image/png', 'image/heic', 'image/webp', @@ -17,12 +16,11 @@ const MAX_SIZE = { } interface Props { - stage: EvidenceStage onFilesChange: (files: File[]) => void disabled?: boolean } -export function FileUpload({ stage, onFilesChange, disabled = false }: Props) { +export function FileUpload({ onFilesChange, disabled = false }: Props) { const inputRef = useRef(null) const [files, setFiles] = useState([]) const [errors, setErrors] = useState([]) diff --git a/lib/supabase/storage.ts b/lib/supabase/storage.ts index b146a4b..36446d0 100644 --- a/lib/supabase/storage.ts +++ b/lib/supabase/storage.ts @@ -8,8 +8,17 @@ export async function uploadEvidenceFile( incidentId: string, stage: EvidenceStage, ): Promise<{ path: string; publicUrl: string; hash: string }> { - const { data: { user } } = await supabase.auth.getUser() - if (!user) throw new Error('Not authenticated') + // Fix 3: Safe auth.getUser() destructuring + const { data, error: authError } = await supabase.auth.getUser() + if (authError || !data?.user) throw new Error('Not authenticated') + const user = data.user + + // Fix 2: Server-side size limit before processing + 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 ext = file.name.split('.').pop() ?? 'bin' const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}` @@ -17,20 +26,26 @@ export async function uploadEvidenceFile( const hash = await computeHash(file) - const { data, error } = await supabase.storage + const { data: uploadData, error } = await supabase.storage .from('evidence') .upload(path, file, { contentType: file.type, upsert: false }) if (error) throw new Error(error.message) - const { data: urlData } = supabase.storage.from('evidence').getPublicUrl(data.path) + // Fix 1: Use createSignedUrl (private bucket) — 10-year URL for DB storage + const { data: signedData, error: signedError } = await supabase.storage + .from('evidence') + .createSignedUrl(uploadData.path, 315360000) // ~10 years + if (signedError || !signedData) throw new Error(signedError?.message ?? 'Failed to sign URL') - return { path: data.path, publicUrl: urlData.publicUrl, hash } + return { path: uploadData.path, publicUrl: signedData.signedUrl, hash } } -export function getEvidenceUrl(supabase: SupabaseClient, path: string): string { - const { data } = supabase.storage.from('evidence').getPublicUrl(path) - return data.publicUrl +// Fix 1: getEvidenceUrl is now async, uses 1-hour signed URL +export async function getEvidenceUrl(supabase: SupabaseClient, path: string): Promise { + const { data, error } = await supabase.storage.from('evidence').createSignedUrl(path, 3600) + if (error || !data) throw new Error(error?.message ?? 'Failed to sign URL') + return data.signedUrl } async function computeHash(file: File): Promise { diff --git a/tests/lib/supabase/storage.test.ts b/tests/lib/supabase/storage.test.ts index 06bd17d..d4e9cc5 100644 --- a/tests/lib/supabase/storage.test.ts +++ b/tests/lib/supabase/storage.test.ts @@ -2,21 +2,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { uploadEvidenceFile, getEvidenceUrl } from '@/lib/supabase/storage' const mockUpload = vi.fn() -const mockGetPublicUrl = vi.fn() +const mockCreateSignedUrl = vi.fn() const mockSupabase = { storage: { from: vi.fn(() => ({ upload: mockUpload, - getPublicUrl: mockGetPublicUrl, + createSignedUrl: mockCreateSignedUrl, })), }, - auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-123' } } }) }, + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-123' } }, error: null }) }, } as any beforeEach(() => { vi.clearAllMocks() mockUpload.mockResolvedValue({ data: { path: 'user-123/incident-abc/report/photo.jpg' }, error: null }) - mockGetPublicUrl.mockReturnValue({ data: { publicUrl: 'https://example.com/photo.jpg' } }) + mockCreateSignedUrl.mockResolvedValue({ data: { signedUrl: 'https://example.com/photo.jpg' }, error: null }) }) describe('uploadEvidenceFile', () => { @@ -29,6 +29,7 @@ describe('uploadEvidenceFile', () => { file, expect.objectContaining({ contentType: 'image/jpeg', upsert: false }) ) + expect(mockCreateSignedUrl).toHaveBeenCalledWith(expect.stringContaining('user-123/incident-abc/report/'), 315360000) expect(result.path).toContain('user-123/incident-abc/report/') expect(result.hash).toBeTruthy() }) @@ -38,12 +39,18 @@ describe('uploadEvidenceFile', () => { const file = new File(['x'], 'f.jpg', { type: 'image/jpeg' }) await expect(uploadEvidenceFile(mockSupabase, file, 'inc', 'report')).rejects.toThrow('Bucket not found') }) + + it('throws when photo exceeds 10MB', async () => { + const bigFile = new File([new Uint8Array(11 * 1024 * 1024)], 'big.jpg', { type: 'image/jpeg' }) + await expect(uploadEvidenceFile(mockSupabase, bigFile, 'inc', 'report')).rejects.toThrow('File too large') + }) }) describe('getEvidenceUrl', () => { - it('returns signed public URL', () => { - const url = getEvidenceUrl(mockSupabase, 'user-123/incident-abc/report/photo.jpg') + it('returns signed URL for path', async () => { + const url = await getEvidenceUrl(mockSupabase, 'user-123/incident-abc/report/photo.jpg') expect(mockSupabase.storage.from).toHaveBeenCalledWith('evidence') + expect(mockCreateSignedUrl).toHaveBeenCalledWith('user-123/incident-abc/report/photo.jpg', 3600) expect(url).toBe('https://example.com/photo.jpg') }) })