mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -7,19 +7,31 @@ import { fetchWithTimeout, resolveBunnyDownloadSource } from '@/lib/bunny-downlo
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
extractImageFileNameFromProxyUrl,
|
||||
extractAudioFileNameFromProxyUrl,
|
||||
getVideoAssetAccessContext,
|
||||
} from '@/lib/video-assets';
|
||||
|
||||
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
|
||||
type BunnySourcePreference = 'auto' | 'original' | 'compressed';
|
||||
|
||||
const CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
const IMAGE_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
};
|
||||
|
||||
const AUDIO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
webm: 'audio/webm',
|
||||
ogg: 'audio/ogg',
|
||||
opus: 'audio/ogg',
|
||||
mp4: 'audio/mp4',
|
||||
m4a: 'audio/mp4',
|
||||
mpeg: 'audio/mpeg',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
};
|
||||
const BUNNY_ALLOWED_QUALITIES = new Set([2160, 1440, 1080, 720, 480, 360, 240]);
|
||||
|
||||
function sanitizeFileName(value: string): string {
|
||||
@@ -47,7 +59,7 @@ function buildContentDisposition(fileNameWithExt: string): string {
|
||||
|
||||
function imageContentTypeFromFileName(fileName: string): string {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
return CONTENT_TYPE_BY_EXTENSION[ext] || 'application/octet-stream';
|
||||
return IMAGE_CONTENT_TYPE_BY_EXTENSION[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
// GET /api/videos/[videoId]/assets/[assetId]/download
|
||||
@@ -101,6 +113,29 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
});
|
||||
}
|
||||
|
||||
if (asset.provider === VideoAssetProvider.R2_AUDIO) {
|
||||
const fileName = extractAudioFileNameFromProxyUrl(asset.sourceUrl);
|
||||
if (!fileName) return apiErrors.badRequest('Invalid audio asset URL');
|
||||
const key = `voice/${fileName}`;
|
||||
const ext = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.webm';
|
||||
const downloadName = `${sanitizeFileName(asset.displayName)}${ext}`;
|
||||
const contentDisposition = buildContentDisposition(downloadName);
|
||||
const extKey = ext.replace('.', '');
|
||||
const contentType = AUDIO_CONTENT_TYPE_BY_EXTENSION[extKey] || 'audio/webm';
|
||||
|
||||
return proxyR2MediaObject({
|
||||
request,
|
||||
key,
|
||||
fallbackContentType: contentType,
|
||||
cacheControl: 'private, no-store',
|
||||
extraHeaders: {
|
||||
'Content-Disposition': contentDisposition,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
internalErrorMessage: 'Failed to retrieve audio',
|
||||
});
|
||||
}
|
||||
|
||||
const sourceParam = request.nextUrl.searchParams.get('source');
|
||||
const rawQuality = request.nextUrl.searchParams.get('quality');
|
||||
const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1';
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
let shouldDeleteImageObject = false;
|
||||
let shouldDeleteAudioObject = false;
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.videoAsset.delete({ where: { id: asset.id } });
|
||||
|
||||
@@ -52,12 +53,23 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
]);
|
||||
shouldDeleteImageObject = assetReferenceCount === 0 && commentReferenceCount === 0;
|
||||
}
|
||||
|
||||
if (asset.provider === VideoAssetProvider.R2_AUDIO) {
|
||||
const [assetReferenceCount, commentReferenceCount] = await Promise.all([
|
||||
tx.videoAsset.count({ where: { sourceUrl: asset.sourceUrl } }),
|
||||
tx.comment.count({ where: { voiceUrl: asset.sourceUrl } }),
|
||||
]);
|
||||
shouldDeleteAudioObject = assetReferenceCount === 0 && commentReferenceCount === 0;
|
||||
}
|
||||
});
|
||||
|
||||
let r2CleanupResult: Awaited<ReturnType<typeof deleteMediaFilesBestEffort>> | undefined;
|
||||
if (asset.provider === VideoAssetProvider.R2_IMAGE && shouldDeleteImageObject) {
|
||||
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
|
||||
}
|
||||
if (asset.provider === VideoAssetProvider.R2_AUDIO && shouldDeleteAudioObject) {
|
||||
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
|
||||
}
|
||||
|
||||
let bunnyCleanupResult: Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>> | undefined;
|
||||
if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) {
|
||||
|
||||
@@ -15,9 +15,12 @@ import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import {
|
||||
SAFE_BUNNY_VIDEO_ID,
|
||||
SAFE_IMAGE_PROXY_PATH,
|
||||
SAFE_AUDIO_PROXY_PATH,
|
||||
canDeleteAssetForViewer,
|
||||
extractImageFileNameFromProxyUrl,
|
||||
extractImageKeyFromProxyUrl,
|
||||
extractAudioKeyFromProxyUrl,
|
||||
extractAudioFileNameFromProxyUrl,
|
||||
getVideoAssetAccessContext,
|
||||
sanitizeAssetDisplayName,
|
||||
} from '@/lib/video-assets';
|
||||
@@ -32,7 +35,7 @@ const YOUTUBE_TITLE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
type AssetWithViewerFields = {
|
||||
id: string;
|
||||
videoId: string;
|
||||
kind: 'IMAGE' | 'VIDEO';
|
||||
kind: 'IMAGE' | 'VIDEO' | 'AUDIO';
|
||||
provider: VideoAssetProvider;
|
||||
displayName: string;
|
||||
sourceUrl: string;
|
||||
@@ -159,6 +162,22 @@ async function isFreshImageAttachment(url: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function isFreshAudioAttachment(url: string): Promise<boolean> {
|
||||
const key = extractAudioKeyFromProxyUrl(url);
|
||||
if (!key) return false;
|
||||
|
||||
try {
|
||||
const head = await r2Client.send(new HeadObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: key,
|
||||
}));
|
||||
if (!head.LastModified) return false;
|
||||
return Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/videos/[videoId]/assets
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
@@ -225,7 +244,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
const response = successResponse({
|
||||
assets: pagedAssets.map((asset) => shapeAssetForViewer(
|
||||
asset,
|
||||
context.canDownloadAssets,
|
||||
// R2_AUDIO proxy URLs have no auth gate — expose them to any viewer so guests can preview audio
|
||||
context.canDownloadAssets || (asset.provider === VideoAssetProvider.R2_AUDIO && context.hasViewAccess),
|
||||
includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false
|
||||
)),
|
||||
pagination: {
|
||||
@@ -259,7 +279,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const provider = typeof body?.provider === 'string' ? body.provider.trim().toUpperCase() : '';
|
||||
|
||||
if (provider !== VideoAssetProvider.R2_IMAGE && provider !== VideoAssetProvider.YOUTUBE && provider !== VideoAssetProvider.BUNNY) {
|
||||
if (
|
||||
provider !== VideoAssetProvider.R2_IMAGE &&
|
||||
provider !== VideoAssetProvider.YOUTUBE &&
|
||||
provider !== VideoAssetProvider.BUNNY &&
|
||||
provider !== VideoAssetProvider.R2_AUDIO
|
||||
) {
|
||||
return apiErrors.badRequest('Invalid provider');
|
||||
}
|
||||
|
||||
@@ -271,7 +296,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
let sourceUrl = '';
|
||||
let providerVideoId: string | null = null;
|
||||
let thumbnailUrl: string | null = null;
|
||||
let kind: 'IMAGE' | 'VIDEO' = 'IMAGE';
|
||||
let kind: 'IMAGE' | 'VIDEO' | 'AUDIO' = 'IMAGE';
|
||||
|
||||
if (provider === VideoAssetProvider.R2_IMAGE) {
|
||||
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
||||
@@ -288,6 +313,20 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
kind = 'IMAGE';
|
||||
}
|
||||
|
||||
if (provider === VideoAssetProvider.R2_AUDIO) {
|
||||
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
||||
if (!SAFE_AUDIO_PROXY_PATH.test(sourceUrl)) {
|
||||
return apiErrors.badRequest('Audio URL must reference an uploaded audio file');
|
||||
}
|
||||
if (!(await isFreshAudioAttachment(sourceUrl))) {
|
||||
return apiErrors.badRequest('Audio upload expired. Please upload again.');
|
||||
}
|
||||
|
||||
const fileName = extractAudioFileNameFromProxyUrl(sourceUrl);
|
||||
displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Voice Recording');
|
||||
kind = 'AUDIO';
|
||||
}
|
||||
|
||||
if (provider === VideoAssetProvider.YOUTUBE) {
|
||||
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
||||
const parsedSource = parseVideoUrl(sourceUrl);
|
||||
|
||||
Reference in New Issue
Block a user