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
+6 -1
View File
@@ -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, {
+6 -1
View File
@@ -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
@@ -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({
<div className="space-y-1">
{files.map((file, index) => (
<div
key={`${file.fileName}-${index}`}
key={file.uploadKey}
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50"
>
{isImageType(file.file.type) ? (
+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')