diff --git a/app/api/documents/[id]/versions/route.ts b/app/api/documents/[id]/versions/route.ts index 2459d087..4a4c00d4 100644 --- a/app/api/documents/[id]/versions/route.ts +++ b/app/api/documents/[id]/versions/route.ts @@ -1,7 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { createNewVersion } from '@/lib/core/documents/document-service' +import { createNewVersion, validateDocumentFile } from '@/lib/core/documents/document-service' import { requireCompanyId } from '@/lib/company/context' ensureInitialized() @@ -37,6 +37,11 @@ export async function POST( return NextResponse.json({ error: 'No file provided' }, { status: 400 }) } + const validationError = validateDocumentFile({ size: file.size, type: file.type }) + if (validationError) { + return NextResponse.json({ error: validationError }, { status: 400 }) + } + const buffer = await file.arrayBuffer() const newVersion = await createNewVersion(supabase, user.id, id, { diff --git a/app/api/documents/route.ts b/app/api/documents/route.ts index d30ff262..b500b3ea 100644 --- a/app/api/documents/route.ts +++ b/app/api/documents/route.ts @@ -1,7 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { uploadDocument } from '@/lib/core/documents/document-service' +import { uploadDocument, validateDocumentFile } from '@/lib/core/documents/document-service' import { requireCompanyId } from '@/lib/company/context' ensureInitialized() @@ -35,6 +35,11 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'No file provided' }, { status: 400 }) } + const validationError = validateDocumentFile({ size: file.size, type: file.type }) + if (validationError) { + return NextResponse.json({ error: validationError }, { status: 400 }) + } + const uploadSource = (formData.get('upload_source') as string) || 'file_upload' const journalEntryId = formData.get('journal_entry_id') as string | null const journalEntryLineId = formData.get('journal_entry_line_id') as string | null diff --git a/components/bookkeeping/DocumentUploadZone.tsx b/components/bookkeeping/DocumentUploadZone.tsx index 2ed48d11..c0af2084 100644 --- a/components/bookkeeping/DocumentUploadZone.tsx +++ b/components/bookkeeping/DocumentUploadZone.tsx @@ -12,6 +12,8 @@ export interface UploadedFile { error?: string fileName: string fileSize: number + /** Unique key to track this upload (handles duplicate filenames) */ + uploadKey: string } interface DocumentUploadZoneProps { @@ -23,6 +25,7 @@ interface DocumentUploadZoneProps { compact?: boolean } +let uploadCounter = 0 const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] const ACCEPTED_EXTENSIONS = '.pdf,.jpg,.jpeg,.png,.webp' @@ -87,6 +90,7 @@ export default function DocumentUploadZone({ error: 'Filtypen stöds inte', fileName: file.name, fileSize: file.size, + uploadKey: `upload-${++uploadCounter}`, }) continue } @@ -97,6 +101,7 @@ export default function DocumentUploadZone({ error: 'Filen är för stor (max 10 MB)', fileName: file.name, fileSize: file.size, + uploadKey: `upload-${++uploadCounter}`, }) continue } @@ -105,6 +110,7 @@ export default function DocumentUploadZone({ status: 'uploading', fileName: file.name, fileSize: file.size, + uploadKey: `upload-${++uploadCounter}`, }) } @@ -115,7 +121,7 @@ export default function DocumentUploadZone({ for (const f of validFiles.filter((f) => f.status === 'uploading')) { const result = await uploadFile(f) currentFiles = currentFiles.map((cf) => - cf.fileName === result.fileName && cf.status === 'uploading' ? result : cf + cf.uploadKey === f.uploadKey ? result : cf ) onFilesChange([...currentFiles]) } @@ -200,7 +206,7 @@ export default function DocumentUploadZone({
{files.map((file, index) => (
{isImageType(file.file.type) ? ( diff --git a/lib/core/documents/document-service.ts b/lib/core/documents/document-service.ts index 7f907262..aae867a3 100644 --- a/lib/core/documents/document-service.ts +++ b/lib/core/documents/document-service.ts @@ -11,6 +11,52 @@ import type { DocumentAttachment, DocumentUploadSource } from '@/types' * for documents linked to committed entries. */ +/** + * Sanitize a filename for use in Supabase Storage keys. + * Replaces spaces and non-ASCII characters with underscores, + * collapses consecutive underscores, and truncates to avoid + * exceeding Supabase Storage path length limits. + */ +function sanitizeFileName(name: string): string { + const dotIndex = name.lastIndexOf('.') + const ext = dotIndex > 0 ? name.slice(dotIndex) : '' + const base = dotIndex > 0 ? name.slice(0, dotIndex) : name + + const sanitizedBase = base + .replace(/[^a-zA-Z0-9._-]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') + .slice(0, 100) || 'file' + const sanitizedExt = ext.replace(/[^a-zA-Z0-9.]/g, '_') + + return sanitizedBase + sanitizedExt +} + +export const MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 // 10 MB +export const ALLOWED_DOCUMENT_TYPES = [ + 'application/pdf', + 'image/jpeg', + 'image/png', + 'image/webp', +] + +/** + * Validate file size and MIME type before upload. + * Returns an error string or null if valid. + */ +export function validateDocumentFile(file: { size: number; type?: string }): string | null { + if (file.size === 0) { + return 'Filen är tom' + } + if (file.size > MAX_DOCUMENT_SIZE) { + return `Filen är för stor (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)` + } + if (!file.type || !ALLOWED_DOCUMENT_TYPES.includes(file.type)) { + return 'Otillåten filtyp. Tillåtna: PDF, JPG, PNG, WebP.' + } + return null +} + let bucketVerified = false /** @internal Reset bucket verification flag — for testing only */ @@ -72,7 +118,7 @@ export async function uploadDocument( // Generate storage path const timestamp = Date.now() - const storagePath = `documents/${userId}/${timestamp}_${file.name}` + const storagePath = `documents/${userId}/${timestamp}_${sanitizeFileName(file.name)}` // Upload to Supabase Storage const { error: uploadError } = await supabase.storage @@ -145,7 +191,7 @@ export async function createNewVersion( // Upload new file to Storage const timestamp = Date.now() - const storagePath = `documents/${userId}/${timestamp}_${file.name}` + const storagePath = `documents/${userId}/${timestamp}_${sanitizeFileName(file.name)}` const { error: uploadError } = await supabase.storage .from('documents')