feat: add audio asset support with recording and upload functionality

- Implemented audio asset handling in the API, including download and deletion routes.
- Added audio content type mappings and extraction functions for audio file names and keys.
- Enhanced the asset management UI to support audio uploads, including recording capabilities.
- Updated the database schema to accommodate audio assets with new enum values.
- Integrated audio playback features in the asset list and comment sections.
- Improved user experience with drag-and-drop support for audio files.
This commit is contained in:
Yusuf İpek
2026-02-28 11:01:10 +03:00
parent 9dde7dce44
commit 975ff603e1
14 changed files with 615 additions and 43 deletions
+86 -7
View File
@@ -15,7 +15,74 @@ import {
} from '@/lib/guest-upload-token';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/wav'];
// Canonical MIME types accepted
const ALLOWED_TYPES = new Set(['audio/webm', 'audio/ogg', 'audio/opus', 'audio/mp4', 'audio/mpeg', 'audio/wav']);
// Normalize known MIME aliases to canonical values
const MIME_ALIASES: Record<string, string> = {
'audio/wave': 'audio/wav',
'audio/vnd.wave': 'audio/wav',
'audio/x-wav': 'audio/wav',
'audio/x-pn-wav': 'audio/wav',
'audio/mp3': 'audio/mpeg',
'audio/x-mpeg': 'audio/mpeg',
};
// Map canonical MIME to fallback file extension
const MIME_TO_EXT: Record<string, string> = {
'audio/webm': 'webm',
'audio/ogg': 'ogg',
'audio/opus': 'opus',
'audio/mp4': 'm4a',
'audio/mpeg': 'mp3',
'audio/wav': 'wav',
};
// Safe extensions to preserve from original filename (prevents path traversal, allows known types)
// Intentionally excludes flac/aac: they have no corresponding MIME in ALLOWED_TYPES and are
// never produced by MediaRecorder, so accepting them would create extension/MIME mismatches.
const SAFE_AUDIO_EXTENSIONS = new Set(['webm', 'ogg', 'opus', 'mp3', 'm4a', 'mp4', 'wav']);
// Reject content that looks like HTML/XML/script regardless of the declared MIME type.
function isHtmlContent(bytes: Buffer): boolean {
const snippet = bytes.toString('latin1', 0, Math.min(bytes.length, 512)).trimStart().slice(0, 50).toLowerCase();
return (
snippet.startsWith('<!doctype') ||
snippet.startsWith('<html') ||
snippet.startsWith('<?xml') ||
snippet.startsWith('<script') ||
snippet.startsWith('<svg')
);
}
// Verify that the first bytes of the file match known audio container signatures.
function hasValidAudioMagicBytes(header: Buffer, mimeType: string): boolean {
if (header.length < 8) return false;
switch (mimeType) {
// WebM / Matroska: EBML header 1a 45 df a3
case 'audio/webm':
return header[0] === 0x1a && header[1] === 0x45 && header[2] === 0xdf && header[3] === 0xa3;
// OGG container (covers ogg vorbis and opus)
case 'audio/ogg':
case 'audio/opus':
return header[0] === 0x4f && header[1] === 0x67 && header[2] === 0x67 && header[3] === 0x53; // "OggS"
// MPEG audio: ID3 tag header or raw MPEG sync frame
case 'audio/mpeg': {
const hasId3 = header[0] === 0x49 && header[1] === 0x44 && header[2] === 0x33; // "ID3"
const hasMpegSync = header[0] === 0xff && (header[1] & 0xe0) === 0xe0;
return hasId3 || hasMpegSync;
}
// MP4 / M4A: ISO base media file; "ftyp" box starts at offset 4
case 'audio/mp4':
return header[4] === 0x66 && header[5] === 0x74 && header[6] === 0x79 && header[7] === 0x70; // "ftyp"
// WAV: RIFF header
case 'audio/wav':
return header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46; // "RIFF"
default:
return false;
}
}
export async function POST(request: NextRequest) {
try {
@@ -101,14 +168,18 @@ export async function POST(request: NextRequest) {
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
}
// Check content type
const contentType = file.type || 'audio/webm';
if (!ALLOWED_TYPES.includes(contentType)) {
return apiErrors.badRequest(`Unsupported audio format: ${contentType}`);
// Normalize content type: strip codec params, then resolve aliases
const rawContentType = file.type || 'audio/webm';
const strippedType = rawContentType.split(';')[0].trim().toLowerCase();
const contentType = MIME_ALIASES[strippedType] ?? strippedType;
if (!ALLOWED_TYPES.has(contentType)) {
return apiErrors.badRequest(`Unsupported audio format: ${rawContentType}`);
}
// Generate unique filename
const ext = contentType.split('/')[1] || 'webm';
// Prefer the original file extension when it's a known safe type (e.g. preserve .opus, .mp3)
// Fall back to MIME-derived extension for blobs without a real name (e.g. MediaRecorder output)
const origExt = (file.name.split('.').pop() ?? '').toLowerCase();
const ext = SAFE_AUDIO_EXTENSIONS.has(origExt) ? origExt : (MIME_TO_EXT[contentType] ?? 'webm');
const filename = `${randomUUID()}.${ext}`;
const key = `voice/${filename}`;
@@ -116,6 +187,14 @@ export async function POST(request: NextRequest) {
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Validate file content against magic bytes — rejects HTML/scripts masquerading as audio
if (isHtmlContent(buffer)) {
return apiErrors.badRequest('File content does not match an audio format');
}
if (!hasValidAudioMagicBytes(buffer.slice(0, 16), contentType)) {
return apiErrors.badRequest('File content does not match the declared audio format');
}
// Upload to R2
await r2Client.send(
new PutObjectCommand({