feat(audio-upload): implement audio file size validation and error handling

This commit is contained in:
Yusuf İpek
2026-03-08 18:46:53 +03:00
parent 8a4553d351
commit 4746085fc3
2 changed files with 52 additions and 6 deletions
+3 -2
View File
@@ -15,6 +15,7 @@ import {
} from '@/lib/guest-upload-token'; } from '@/lib/guest-upload-token';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB 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 // Canonical MIME types accepted
const ALLOWED_TYPES = new Set(['audio/webm', 'audio/ogg', 'audio/opus', 'audio/mp4', 'audio/mpeg', 'audio/wav']); 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 // Check Content-Length header BEFORE loading the file
const contentLength = request.headers.get('content-length'); const contentLength = request.headers.get('content-length');
if (contentLength) { if (contentLength) {
const fileSize = parseInt(contentLength, 10); const bodySize = parseInt(contentLength, 10);
if (isNaN(fileSize) || fileSize > MAX_FILE_SIZE) { if (isNaN(bodySize) || bodySize > MAX_MULTIPART_BODY_SIZE) {
return apiErrors.badRequest('File too large. Maximum size is 10MB.'); return apiErrors.badRequest('File too large. Maximum size is 10MB.');
} }
} }
+49 -4
View File
@@ -24,11 +24,39 @@ import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { cn } from '@/lib/utils'; 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 { function formatTime(seconds: number): string {
const s = Math.floor(seconds); const s = Math.floor(seconds);
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; 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<UploadAudioResponse | null> {
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 { interface AssetsPaneProps {
videoId: string; videoId: string;
assets: VideoAsset[]; assets: VideoAsset[];
@@ -513,6 +541,13 @@ export const AssetsPane = memo(function AssetsPane({
const handleVoiceUpload = useCallback(async () => { const handleVoiceUpload = useCallback(async () => {
const uploadSource = pendingAudioFile ?? audioBlob; const uploadSource = pendingAudioFile ?? audioBlob;
if (!uploadSource) return; if (!uploadSource) return;
const validationError = getAudioUploadValidationError(uploadSource);
if (validationError) {
toast.error(validationError);
return;
}
setIsUploadingVoice(true); setIsUploadingVoice(true);
try { try {
const formData = new FormData(); const formData = new FormData();
@@ -529,10 +564,14 @@ export const AssetsPane = memo(function AssetsPane({
method: 'POST', method: 'POST',
body: formData, 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; const uploadedUrl = uploadPayload?.data?.url;
if (!uploadRes.ok || !uploadedUrl) { 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; return;
} }
@@ -548,8 +587,9 @@ export const AssetsPane = memo(function AssetsPane({
setAudioBlob(null); setAudioBlob(null);
setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
setPendingAudioFile(null); setPendingAudioFile(null);
} catch { } catch (error) {
toast.error('Failed to upload voice recording'); console.error('Failed to upload voice asset:', error);
toast.error(error instanceof Error ? error.message : 'Failed to upload voice recording');
} finally { } finally {
setIsUploadingVoice(false); setIsUploadingVoice(false);
} }
@@ -592,6 +632,11 @@ export const AssetsPane = memo(function AssetsPane({
setUploadTab('bunny'); setUploadTab('bunny');
await handleBunnyFileUpload(file); await handleBunnyFileUpload(file);
} else if (file.type.startsWith('audio/')) { } 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 // Stage the file so the user can optionally set a name before uploading
setUploadTab('voice'); setUploadTab('voice');
setPendingAudioFile(file); setPendingAudioFile(file);