From 1dcec370b65ac994ad9326c02454b5490cdc7553 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:44:38 +0200 Subject: [PATCH] fix: sanitize document filenames and add upload validation (#171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: sanitize document filenames and add server-side upload validation Filenames with spaces or non-ASCII characters (e.g. Swedish ö, ä, å) caused Supabase Storage to reject uploads with "Invalid key". This adds filename sanitization, server-side size/type validation on both upload routes, and fixes a duplicate-filename race condition in the upload UI. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: require MIME type and handle empty sanitized filenames Address review feedback: - MIME type check now rejects files with missing/empty Content-Type instead of silently allowing them through - Fallback to 'file' when sanitized base is empty (e.g. ööö.pdf) Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- app/api/documents/[id]/versions/route.ts | 7 ++- app/api/documents/route.ts | 7 ++- components/bookkeeping/DocumentUploadZone.tsx | 10 +++- lib/core/documents/document-service.ts | 50 ++++++++++++++++++- 4 files changed, 68 insertions(+), 6 deletions(-) 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')