From 4746085fc3fbbf87eaea55fa7559cd651d7d83aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Sun, 8 Mar 2026 18:46:53 +0300 Subject: [PATCH] feat(audio-upload): implement audio file size validation and error handling --- app/api/upload/audio/route.ts | 5 ++- components/video-page/assets-pane.tsx | 53 +++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/app/api/upload/audio/route.ts b/app/api/upload/audio/route.ts index 747cfb8..c4b6ef5 100644 --- a/app/api/upload/audio/route.ts +++ b/app/api/upload/audio/route.ts @@ -15,6 +15,7 @@ import { } from '@/lib/guest-upload-token'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +const MAX_MULTIPART_BODY_SIZE = MAX_FILE_SIZE + (512 * 1024); // file + multipart overhead // Canonical MIME types accepted const ALLOWED_TYPES = new Set(['audio/webm', 'audio/ogg', 'audio/opus', 'audio/mp4', 'audio/mpeg', 'audio/wav']); @@ -89,8 +90,8 @@ export async function POST(request: NextRequest) { // Check Content-Length header BEFORE loading the file const contentLength = request.headers.get('content-length'); if (contentLength) { - const fileSize = parseInt(contentLength, 10); - if (isNaN(fileSize) || fileSize > MAX_FILE_SIZE) { + const bodySize = parseInt(contentLength, 10); + if (isNaN(bodySize) || bodySize > MAX_MULTIPART_BODY_SIZE) { return apiErrors.badRequest('File too large. Maximum size is 10MB.'); } } diff --git a/components/video-page/assets-pane.tsx b/components/video-page/assets-pane.tsx index f09569d..5e20697 100644 --- a/components/video-page/assets-pane.tsx +++ b/components/video-page/assets-pane.tsx @@ -24,11 +24,39 @@ import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { cn } from '@/lib/utils'; +const MAX_AUDIO_UPLOAD_SIZE = 10 * 1024 * 1024; // 10MB +const MAX_AUDIO_UPLOAD_SIZE_MESSAGE = 'File too large. Maximum size is 10MB.'; + function formatTime(seconds: number): string { const s = Math.floor(seconds); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; } +type UploadAudioResponse = { + data?: { url?: string }; + error?: string; +}; + +function getAudioUploadValidationError(file: Blob): string | null { + if (file.size > MAX_AUDIO_UPLOAD_SIZE) { + return MAX_AUDIO_UPLOAD_SIZE_MESSAGE; + } + return null; +} + +async function readUploadAudioResponse(response: Response): Promise { + const raw = await response.text().catch(() => ''); + const trimmed = raw.trim(); + if (!trimmed) return null; + + try { + return JSON.parse(trimmed) as UploadAudioResponse; + } catch { + if (trimmed.startsWith('<')) return null; + return { error: trimmed }; + } +} + interface AssetsPaneProps { videoId: string; assets: VideoAsset[]; @@ -513,6 +541,13 @@ export const AssetsPane = memo(function AssetsPane({ const handleVoiceUpload = useCallback(async () => { const uploadSource = pendingAudioFile ?? audioBlob; if (!uploadSource) return; + + const validationError = getAudioUploadValidationError(uploadSource); + if (validationError) { + toast.error(validationError); + return; + } + setIsUploadingVoice(true); try { const formData = new FormData(); @@ -529,10 +564,14 @@ export const AssetsPane = memo(function AssetsPane({ method: 'POST', body: formData, }); - const uploadPayload = (await uploadRes.json().catch(() => null)) as { data?: { url?: string }; error?: string } | null; + const uploadPayload = await readUploadAudioResponse(uploadRes); const uploadedUrl = uploadPayload?.data?.url; if (!uploadRes.ok || !uploadedUrl) { - toast.error(uploadPayload?.error || 'Failed to upload voice recording'); + toast.error( + uploadPayload?.error + || (uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null) + || 'Failed to upload voice recording' + ); return; } @@ -548,8 +587,9 @@ export const AssetsPane = memo(function AssetsPane({ setAudioBlob(null); setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); setPendingAudioFile(null); - } catch { - toast.error('Failed to upload voice recording'); + } catch (error) { + console.error('Failed to upload voice asset:', error); + toast.error(error instanceof Error ? error.message : 'Failed to upload voice recording'); } finally { setIsUploadingVoice(false); } @@ -592,6 +632,11 @@ export const AssetsPane = memo(function AssetsPane({ setUploadTab('bunny'); await handleBunnyFileUpload(file); } else if (file.type.startsWith('audio/')) { + const audioError = getAudioUploadValidationError(file); + if (audioError) { + toast.error(audioError); + return; + } // Stage the file so the user can optionally set a name before uploading setUploadTab('voice'); setPendingAudioFile(file);