fix: sanitize document filenames and add upload validation (#171)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-03 14:44:38 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent f95e6437f2
commit 1dcec370b6
4 changed files with 68 additions and 6 deletions
+48 -2
View File
@@ -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')