diff --git a/app/api/versions/[versionId]/download/route.ts b/app/api/versions/[versionId]/download/route.ts index 80f44c9..b1123ad 100644 --- a/app/api/versions/[versionId]/download/route.ts +++ b/app/api/versions/[versionId]/download/route.ts @@ -4,11 +4,39 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response import { rateLimit } from '@/lib/rate-limit'; type RouteParams = { params: Promise<{ versionId: string }> }; +type BunnyDownloadSourcePreference = 'auto' | 'original' | 'compressed'; +type BunnyDownloadSource = { + sourceType: 'original' | 'compressed'; + quality: number | null; + url: string; +}; const BUNNY_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240]; const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS); const DEFAULT_BUNNY_CDN_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net'; -const DOWNLOAD_RATE_LIMIT = { windowMs: 60 * 1000, maxRequests: 10 }; +const BUNNY_MAX_PROBE_CANDIDATES = 4; +const BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS = 60 * 1000; +const BUNNY_REMOTE_FETCH_TIMEOUT_MS = 8 * 1000; +const SAFE_DOWNLOAD_CONTENT_TYPE = 'application/octet-stream'; +const CONTENT_TYPE_EXTENSION_MAP: Record = { + 'video/mp4': '.mp4', + 'video/quicktime': '.mov', + 'video/webm': '.webm', + 'video/x-matroska': '.mkv', + 'video/x-msvideo': '.avi', + 'video/mpeg': '.mpeg', + 'video/3gpp': '.3gp', + 'video/ogg': '.ogv', +}; +const SAFE_VIDEO_CONTENT_TYPES = new Set(Object.keys(CONTENT_TYPE_EXTENSION_MAP)); +const SAFE_VIDEO_EXTENSIONS = new Set(Object.values(CONTENT_TYPE_EXTENSION_MAP)); + +type BunnyDownloadSourceCacheRecord = { + source: BunnyDownloadSource | null; + expiresAt: number; +}; + +const bunnyDownloadSourceCache = new Map(); function sanitizeFileName(value: string): string { const sanitized = value @@ -45,44 +73,92 @@ function resolveBunnyCdnHostname(): string { } } +function buildBunnyOriginalUrl(videoId: string): string { + return `https://${resolveBunnyCdnHostname()}/${videoId}/original`; +} + +function buildBunnySourceCacheKey( + videoId: string, + requestedQuality: number | null, + sourcePreference: BunnyDownloadSourcePreference +): string { + return `${videoId}:${requestedQuality ?? 'none'}:${sourcePreference}`; +} + +function getCachedBunnyDownloadSource(cacheKey: string, now: number): BunnyDownloadSource | null | undefined { + const cached = bunnyDownloadSourceCache.get(cacheKey); + if (!cached) return undefined; + + if (cached.expiresAt <= now) { + bunnyDownloadSourceCache.delete(cacheKey); + return undefined; + } + + return cached.source; +} + +function setCachedBunnyDownloadSource(cacheKey: string, source: BunnyDownloadSource | null, now: number): void { + bunnyDownloadSourceCache.set(cacheKey, { + source, + expiresAt: now + BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS, + }); +} + +async function fetchWithTimeout(url: string, init: RequestInit): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), BUNNY_REMOTE_FETCH_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +async function isRemoteFileAvailable(url: string): Promise { + try { + const headRes = await fetchWithTimeout(url, { method: 'HEAD', cache: 'no-store' }); + if (headRes.ok) return true; + + if (headRes.status === 405) { + const rangeRes = await fetchWithTimeout(url, { + method: 'GET', + headers: { Range: 'bytes=0-0' }, + cache: 'no-store', + }); + return rangeRes.ok || rangeRes.status === 206; + } + + return false; + } catch { + return false; + } +} + async function resolveHighestBunnyMp4Url(videoId: string): Promise { const hostname = resolveBunnyCdnHostname(); const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`; let playlistHeights: number[] = []; try { - const playlistRes = await fetch(playlistUrl, { cache: 'no-store' }); + const playlistRes = await fetchWithTimeout(playlistUrl, { cache: 'no-store' }); if (playlistRes.ok) { const playlist = await playlistRes.text(); const matches = [...playlist.matchAll(/RESOLUTION=\d+x(\d+)/g)]; playlistHeights = matches .map((match) => Number(match[1])) - .filter((height) => Number.isFinite(height) && height > 0) + .filter((height) => Number.isFinite(height) && BUNNY_ALLOWED_QUALITIES.has(height)) .sort((a, b) => b - a); } } catch { // Continue with static fallback list below. } - const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])]; + const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])] + .slice(0, BUNNY_MAX_PROBE_CANDIDATES); for (const height of candidateHeights) { const candidateUrl = `https://${hostname}/${videoId}/play_${height}p.mp4`; - try { - const headRes = await fetch(candidateUrl, { method: 'HEAD', cache: 'no-store' }); - if (headRes.ok) return candidateUrl; - - if (headRes.status === 405) { - const rangeRes = await fetch(candidateUrl, { - method: 'GET', - headers: { Range: 'bytes=0-0' }, - cache: 'no-store', - }); - if (rangeRes.ok || rangeRes.status === 206) return candidateUrl; - } - } catch { - // Try next candidate. - } + if (await isRemoteFileAvailable(candidateUrl)) return candidateUrl; } // Last-resort fallback @@ -90,6 +166,76 @@ async function resolveHighestBunnyMp4Url(videoId: string): Promise { return `https://${hostname}/${videoId}/play_${fallbackHeight}p.mp4`; } +async function resolveBunnyOriginalSource(videoId: string): Promise { + const originalUrl = buildBunnyOriginalUrl(videoId); + if (await isRemoteFileAvailable(originalUrl)) { + return { + sourceType: 'original', + quality: null, + url: originalUrl, + }; + } + + return null; +} + +async function resolveBunnyCompressedSource(videoId: string, requestedQuality: number | null): Promise { + if (typeof requestedQuality === 'number' && Number.isFinite(requestedQuality) && requestedQuality > 0) { + const requestedUrl = `https://${resolveBunnyCdnHostname()}/${videoId}/play_${requestedQuality}p.mp4`; + if (await isRemoteFileAvailable(requestedUrl)) { + return { + sourceType: 'compressed', + quality: extractHeightFromBunnyMp4Url(requestedUrl), + url: requestedUrl, + }; + } + } + + const fallbackUrl = await resolveHighestBunnyMp4Url(videoId); + + return { + sourceType: 'compressed', + quality: extractHeightFromBunnyMp4Url(fallbackUrl), + url: fallbackUrl, + }; +} + +async function resolveBunnyDownloadSource( + videoId: string, + requestedQuality: number | null, + sourcePreference: BunnyDownloadSourcePreference +): Promise { + const now = Date.now(); + const cacheKey = buildBunnySourceCacheKey(videoId, requestedQuality, sourcePreference); + const cached = getCachedBunnyDownloadSource(cacheKey, now); + if (cached !== undefined) { + return cached; + } + + let resolvedSource: BunnyDownloadSource | null; + if (sourcePreference === 'original') { + resolvedSource = await resolveBunnyOriginalSource(videoId); + setCachedBunnyDownloadSource(cacheKey, resolvedSource, now); + return resolvedSource; + } + + if (sourcePreference === 'compressed') { + resolvedSource = await resolveBunnyCompressedSource(videoId, requestedQuality); + setCachedBunnyDownloadSource(cacheKey, resolvedSource, now); + return resolvedSource; + } + + const originalSource = await resolveBunnyOriginalSource(videoId); + if (originalSource) { + setCachedBunnyDownloadSource(cacheKey, originalSource, now); + return originalSource; + } + + resolvedSource = await resolveBunnyCompressedSource(videoId, requestedQuality); + setCachedBunnyDownloadSource(cacheKey, resolvedSource, now); + return resolvedSource; +} + function extractHeightFromBunnyMp4Url(url: string): number | null { const match = url.match(/\/play_(\d+)p\.mp4$/); if (!match?.[1]) return null; @@ -97,18 +243,85 @@ function extractHeightFromBunnyMp4Url(url: string): number | null { return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } +function extractFileNameFromContentDisposition(contentDisposition: string | null): string | null { + if (!contentDisposition) return null; + + const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1]); + } catch { + return utf8Match[1]; + } + } + + const fallbackMatch = contentDisposition.match(/filename="?([^";]+)"?/i); + return fallbackMatch?.[1] ?? null; +} + +function extractFileExtension(fileName: string | null): string | null { + if (!fileName) return null; + const dotIndex = fileName.lastIndexOf('.'); + if (dotIndex <= 0 || dotIndex === fileName.length - 1) return null; + const extension = fileName.slice(dotIndex).toLowerCase(); + return /^[.][a-z0-9]{1,10}$/i.test(extension) ? extension : null; +} + +function inferExtensionFromContentType(contentType: string | null): string | null { + if (!contentType) return null; + const normalized = contentType.split(';')[0]?.trim().toLowerCase(); + return normalized ? CONTENT_TYPE_EXTENSION_MAP[normalized] ?? null : null; +} + +function normalizeContentType(contentType: string | null): string | null { + if (!contentType) return null; + const normalized = contentType.split(';')[0]?.trim().toLowerCase(); + return normalized || null; +} + +function resolveSafeDownloadMetadata( + sourceType: BunnyDownloadSource['sourceType'], + sourceFileName: string | null, + sourceContentType: string | null +): { extension: string; contentType: string } | null { + const rawExtension = extractFileExtension(sourceFileName); + const sourceExtension = rawExtension && SAFE_VIDEO_EXTENSIONS.has(rawExtension) ? rawExtension : null; + + const normalizedContentType = normalizeContentType(sourceContentType); + const safeContentType = + normalizedContentType && SAFE_VIDEO_CONTENT_TYPES.has(normalizedContentType) + ? normalizedContentType + : null; + const inferredExtension = safeContentType ? inferExtensionFromContentType(safeContentType) : null; + const fallbackExtension = sourceType === 'compressed' ? '.mp4' : null; + + const extension = sourceExtension || inferredExtension || fallbackExtension; + if (!extension) return null; + + return { + extension, + contentType: safeContentType ?? (sourceType === 'compressed' ? 'video/mp4' : SAFE_DOWNLOAD_CONTENT_TYPE), + }; +} + // GET /api/versions/[versionId]/download export async function GET(request: Request, { params }: RouteParams) { try { - const limited = await rateLimit(request, 'video-download', DOWNLOAD_RATE_LIMIT); + const { searchParams } = new URL(request.url); + const isPrepareOnly = searchParams.get('prepare') === '1'; + const rateLimitAction = isPrepareOnly ? 'video-download-prepare' : 'video-download'; + const limited = await rateLimit(request, rateLimitAction); if (limited) return limited; const session = await auth(); const { versionId } = await params; - const { searchParams } = new URL(request.url); - const isPrepareOnly = searchParams.get('prepare') === '1'; const requestedQuality = Number(searchParams.get('quality')); const rawQuality = searchParams.get('quality'); + const sourceParam = searchParams.get('source'); + const sourcePreference: BunnyDownloadSourcePreference = + sourceParam === null ? 'auto' : sourceParam === 'original' || sourceParam === 'compressed' + ? sourceParam + : 'auto'; const version = await db.videoVersion.findUnique({ where: { id: versionId }, @@ -134,6 +347,10 @@ export async function GET(request: Request, { params }: RouteParams) { return apiErrors.badRequest('Download is currently supported for Bunny versions only'); } + 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)) @@ -141,33 +358,57 @@ export async function GET(request: Request, { params }: RouteParams) { return apiErrors.badRequest('Invalid quality. Allowed values: 2160, 1440, 1080, 720, 480, 360, 240'); } - const sourceUrl = Number.isFinite(requestedQuality) && requestedQuality > 0 - ? `https://${resolveBunnyCdnHostname()}/${version.videoId}/play_${requestedQuality}p.mp4` - : await resolveHighestBunnyMp4Url(version.videoId); - const resolvedQuality = extractHeightFromBunnyMp4Url(sourceUrl); + if (rawQuality !== null && sourcePreference === 'original') { + return apiErrors.badRequest('Quality cannot be used when source=original'); + } + + const source = await resolveBunnyDownloadSource( + version.videoId, + 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: resolvedQuality, + quality: source.quality, + sourceType: source.sourceType, }); return withCacheControl(response, 'private, no-store'); } - const upstream = await fetch(sourceUrl, { cache: 'no-store' }); + const upstream = await fetchWithTimeout(source.url, { cache: 'no-store' }); if (!upstream.ok || !upstream.body) { return apiErrors.notFound('Download file'); } const versionLabel = version.versionLabel?.trim() || `v${version.versionNumber}`; - const filename = sanitizeFileName(`${version.video.title} ${versionLabel}`) + '.mp4'; + const sourceFileName = extractFileNameFromContentDisposition(upstream.headers.get('content-disposition')); + const metadata = resolveSafeDownloadMetadata( + source.sourceType, + sourceFileName, + upstream.headers.get('content-type') + ); + if (!metadata) { + return apiErrors.badRequest('Original file format is not supported for download'); + } + + const filename = sanitizeFileName(`${version.video.title} ${versionLabel}`) + metadata.extension; const contentDisposition = buildContentDisposition(filename); const response = new Response(upstream.body, { status: 200, headers: { - 'Content-Type': upstream.headers.get('content-type') || 'video/mp4', + 'Content-Type': metadata.contentType, 'Content-Disposition': contentDisposition, 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', }, }); diff --git a/components/LandingPage.tsx b/components/LandingPage.tsx index 8f4821e..64421f7 100644 --- a/components/LandingPage.tsx +++ b/components/LandingPage.tsx @@ -876,6 +876,10 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) { 100 GB of direct video upload storage (+$5 per next 100 GB) +
  • + + Download uploaded original files as-is +
  • Instant Webhook (Telegram/Email) Setup diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index a2445d7..4a3b8d1 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -238,6 +238,8 @@ interface BunnyQualityOption { } type BunnyPlaybackState = 'none' | 'processing' | 'error'; +type BunnyDownloadPreference = 'original' | 'compressed'; +type DownloadTarget = BunnyDownloadPreference | 'direct'; export type VideoPageMode = 'dashboard' | 'watch'; @@ -300,7 +302,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const [showResolved, setShowResolved] = useState(false); const [isExportingCsv, setIsExportingCsv] = useState(false); const [isExportingPdf, setIsExportingPdf] = useState(false); - const [isDownloadingVideo, setIsDownloadingVideo] = useState(false); + const [activeDownloadTarget, setActiveDownloadTarget] = useState(null); // Watch progress state const [savedProgress, setSavedProgress] = useState(null); @@ -542,6 +544,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi video?.versions?.[0]; }, [video?.versions, activeVersionId]); + const isDownloadingVideo = activeDownloadTarget !== null; + const isVideoDownloadAvailable = useMemo(() => { if (!activeVersion) return false; if (activeVersion.providerId === 'bunny') return true; @@ -549,31 +553,35 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi return !!getSafeDirectDownloadUrl(activeVersion.originalUrl); }, [activeVersion]); - const handleDownloadVideo = useCallback(async () => { + const handleDownloadVideo = useCallback(async (preference: BunnyDownloadPreference = 'compressed') => { if (!activeVersion || !video || isDownloadingVideo) return; if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') { toast.error('This video source does not support direct download'); return; } - setIsDownloadingVideo(true); + const target: DownloadTarget = activeVersion.providerId === 'bunny' ? preference : 'direct'; + setActiveDownloadTarget(target); try { let downloadUrl: string | null = null; if (activeVersion.providerId === 'bunny') { - const prepareRes = await fetch(`/api/versions/${activeVersion.id}/download?prepare=1`, { + const prepareRes = await fetch(`/api/versions/${activeVersion.id}/download?source=${preference}&prepare=1`, { cache: 'no-store', }); + if (!prepareRes.ok) { - throw new Error('Failed to prepare download'); + const prepareBody = await prepareRes.json().catch(() => null); + const fallbackError = preference === 'original' + ? 'Original file is not available for this video' + : 'Compressed file is not available for this video'; + const errorMessage = typeof prepareBody?.error === 'string' + ? prepareBody.error + : fallbackError; + throw new Error(errorMessage); } - const prepareBody = await prepareRes.json().catch(() => null); - const quality = Number(prepareBody?.data?.quality); - const qualityQuery = Number.isFinite(quality) && quality > 0 - ? `?quality=${quality}` - : ''; - downloadUrl = `/api/versions/${activeVersion.id}/download${qualityQuery}`; + downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`; } else { downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl); if (!downloadUrl) { @@ -589,7 +597,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video'; const a = document.createElement('a'); a.href = downloadUrl; - a.download = `${baseName}.mp4`; + if (activeVersion.providerId === 'direct') { + a.download = `${baseName}.mp4`; + } document.body.appendChild(a); a.click(); a.remove(); @@ -597,11 +607,13 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi console.error('Failed to start video download:', error); if (error instanceof Error && error.message === 'Direct download URL is not allowed') { toast.error('This direct download host is not allowed'); + } else if (error instanceof Error && error.message) { + toast.error(error.message); } else { toast.error('Failed to start download'); } } finally { - setIsDownloadingVideo(false); + setActiveDownloadTarget(null); } }, [activeVersion, isDownloadingVideo, video]); @@ -2800,23 +2812,77 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi - + {activeVersion?.providerId === 'bunny' ? ( + + + + + + { + event.preventDefault(); + void handleDownloadVideo('original'); + }} + disabled={!isVideoDownloadAvailable || isDownloadingVideo} + > + {activeDownloadTarget === 'original' ? ( + + ) : ( + + )} + Download Original + + { + event.preventDefault(); + void handleDownloadVideo('compressed'); + }} + disabled={!isVideoDownloadAvailable || isDownloadingVideo} + > + {activeDownloadTarget === 'compressed' ? ( + + ) : ( + + )} + Download Compressed + + + + ) : ( + + )} {mode === 'dashboard' && ( <> @@ -2961,20 +3027,53 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi - { - event.preventDefault(); - void handleDownloadVideo(); - }} - disabled={!isVideoDownloadAvailable || isDownloadingVideo} - > - {isDownloadingVideo ? ( - - ) : ( - - )} - Download - + {activeVersion?.providerId === 'bunny' ? ( + <> + { + event.preventDefault(); + void handleDownloadVideo('original'); + }} + disabled={!isVideoDownloadAvailable || isDownloadingVideo} + > + {activeDownloadTarget === 'original' ? ( + + ) : ( + + )} + Download Original + + { + event.preventDefault(); + void handleDownloadVideo('compressed'); + }} + disabled={!isVideoDownloadAvailable || isDownloadingVideo} + > + {activeDownloadTarget === 'compressed' ? ( + + ) : ( + + )} + Download Compressed + + + ) : ( + { + event.preventDefault(); + void handleDownloadVideo(); + }} + disabled={!isVideoDownloadAvailable || isDownloadingVideo} + > + {isDownloadingVideo ? ( + + ) : ( + + )} + Download + + )} setShowVersionDialog(true)}> New Version diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index d86a30b..8f5732e 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -29,6 +29,10 @@ export const RATE_LIMIT_CONFIGS: Record = { // Watch progress — allow frequent updates but prevent abuse 'watch-progress': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute (pausing + periodic + visibility changes) + // Downloads — strict enough to limit upstream probing/cost abuse + 'video-download': { windowMs: 60 * 1000, maxRequests: 8 }, // 8 per minute + 'video-download-prepare': { windowMs: 60 * 1000, maxRequests: 5 }, // 5 per minute + // Member management 'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour 'manage-member': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute