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:
co-authored by
Claude Opus 4.6
parent
f95e6437f2
commit
1dcec370b6
@@ -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, {
|
||||
|
||||
@@ -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) ? (
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user