import { NextRequest } from 'next/server'; import { VideoAssetProvider } from '@prisma/client'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { rateLimit } from '@/lib/rate-limit'; import { proxyR2MediaObject } from '@/lib/r2-media-proxy'; import { fetchWithTimeout, resolveBunnyDownloadSource } from '@/lib/bunny-download'; import { db } from '@/lib/db'; import { extractImageFileNameFromProxyUrl, getVideoAssetAccessContext, } from '@/lib/video-assets'; type RouteParams = { params: Promise<{ videoId: string; assetId: string }> }; type BunnySourcePreference = 'auto' | 'original' | 'compressed'; const CONTENT_TYPE_BY_EXTENSION: Record = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', }; const BUNNY_ALLOWED_QUALITIES = new Set([2160, 1440, 1080, 720, 480, 360, 240]); function sanitizeFileName(value: string): string { const sanitized = value .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-') .replace(/\s+/g, ' ') .trim(); return sanitized.length > 0 ? sanitized : 'asset'; } function toAsciiFileName(value: string): string { const normalized = value .normalize('NFKD') .replace(/[^\x20-\x7E]/g, '') .replace(/\s+/g, ' ') .trim(); return normalized.length > 0 ? normalized : 'asset'; } function buildContentDisposition(fileNameWithExt: string): string { const asciiFallback = toAsciiFileName(fileNameWithExt).replace(/["\\]/g, '_'); const encoded = encodeURIComponent(fileNameWithExt); return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`; } function imageContentTypeFromFileName(fileName: string): string { const ext = fileName.split('.').pop()?.toLowerCase() || ''; return CONTENT_TYPE_BY_EXTENSION[ext] || 'application/octet-stream'; } // GET /api/videos/[videoId]/assets/[assetId]/download export async function GET(request: NextRequest, { params }: RouteParams) { try { const limited = await rateLimit(request, 'asset-download'); if (limited) return limited; const { videoId, assetId } = await params; const context = await getVideoAssetAccessContext(request, videoId, 'VIEW'); if (!context) return apiErrors.notFound('Video'); if (!context.hasViewAccess) return apiErrors.forbidden('Access denied'); if (!context.viewerUserId || !context.canDownloadAssets) { return apiErrors.forbidden('Asset downloads require an authenticated account'); } const asset = await db.videoAsset.findFirst({ where: { id: assetId, videoId }, select: { id: true, provider: true, displayName: true, sourceUrl: true, providerVideoId: true, }, }); if (!asset) return apiErrors.notFound('Asset'); if (asset.provider === VideoAssetProvider.YOUTUBE) { return apiErrors.badRequest('YouTube assets cannot be downloaded'); } if (asset.provider === VideoAssetProvider.R2_IMAGE) { const fileName = extractImageFileNameFromProxyUrl(asset.sourceUrl); if (!fileName) return apiErrors.badRequest('Invalid image asset URL'); const key = `images/${fileName}`; const extension = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.png'; const downloadName = `${sanitizeFileName(asset.displayName)}${extension}`; const contentDisposition = buildContentDisposition(downloadName); return proxyR2MediaObject({ request, key, fallbackContentType: imageContentTypeFromFileName(fileName), cacheControl: 'private, no-store', extraHeaders: { 'Content-Disposition': contentDisposition, 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; sandbox", }, internalErrorMessage: 'Failed to retrieve image', }); } const sourceParam = request.nextUrl.searchParams.get('source'); const rawQuality = request.nextUrl.searchParams.get('quality'); const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1'; const requestedQuality = Number(rawQuality); const sourcePreference: BunnySourcePreference = sourceParam === null ? 'auto' : sourceParam === 'original' || sourceParam === 'compressed' ? sourceParam : 'auto'; if (sourceParam !== null && sourceParam !== 'original' && sourceParam !== 'compressed') { return apiErrors.badRequest('Invalid source. Allowed values: original, compressed'); } if ( rawQuality !== null && (!Number.isFinite(requestedQuality) || !BUNNY_ALLOWED_QUALITIES.has(requestedQuality)) ) { return apiErrors.badRequest('Invalid quality. Allowed values: 2160, 1440, 1080, 720, 480, 360, 240'); } if (rawQuality !== null && sourcePreference === 'original') { return apiErrors.badRequest('Quality cannot be used when source=original'); } if (!asset.providerVideoId) { return apiErrors.badRequest('Missing Bunny asset video id'); } const source = await resolveBunnyDownloadSource( asset.providerVideoId, Number.isFinite(requestedQuality) ? requestedQuality : null, sourcePreference ); if (!source) { if (sourcePreference === 'original') { return apiErrors.notFound('Original file'); } return apiErrors.notFound('Download file'); } if (isPrepareOnly) { const response = successResponse({ quality: source.quality, sourceType: source.sourceType, }); return withCacheControl(response, 'private, no-store'); } const upstream = await fetchWithTimeout(source.url, { cache: 'no-store' }); if (!upstream.ok || !upstream.body) { return apiErrors.notFound('Download file'); } const extension = source.sourceType === 'compressed' ? '.mp4' : ''; const filename = `${sanitizeFileName(asset.displayName)}${extension}`; const response = new Response(upstream.body, { status: 200, headers: { 'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream', 'Content-Disposition': buildContentDisposition(filename), 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff', }, }); const contentLength = upstream.headers.get('content-length'); if (contentLength) response.headers.set('Content-Length', contentLength); return withCacheControl(response, 'private, no-store'); } catch (error) { console.error('Error downloading asset:', error); return apiErrors.internalError('Failed to download asset'); } }