feat(video-download): add Bunny original/compressed download options with secure file handling and tighter rate limits

This commit is contained in:
Yusuf İpek
2026-02-22 16:33:52 +03:00
parent f224b0a8a1
commit 0288fe08d3
4 changed files with 422 additions and 74 deletions
+271 -30
View File
@@ -4,11 +4,39 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
import { rateLimit } from '@/lib/rate-limit'; import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ versionId: string }> }; 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_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240];
const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS); const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS);
const DEFAULT_BUNNY_CDN_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net'; 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<string, string> = {
'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<string, BunnyDownloadSourceCacheRecord>();
function sanitizeFileName(value: string): string { function sanitizeFileName(value: string): string {
const sanitized = value 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<Response> {
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<boolean> {
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<string> { async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
const hostname = resolveBunnyCdnHostname(); const hostname = resolveBunnyCdnHostname();
const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`; const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`;
let playlistHeights: number[] = []; let playlistHeights: number[] = [];
try { try {
const playlistRes = await fetch(playlistUrl, { cache: 'no-store' }); const playlistRes = await fetchWithTimeout(playlistUrl, { cache: 'no-store' });
if (playlistRes.ok) { if (playlistRes.ok) {
const playlist = await playlistRes.text(); const playlist = await playlistRes.text();
const matches = [...playlist.matchAll(/RESOLUTION=\d+x(\d+)/g)]; const matches = [...playlist.matchAll(/RESOLUTION=\d+x(\d+)/g)];
playlistHeights = matches playlistHeights = matches
.map((match) => Number(match[1])) .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); .sort((a, b) => b - a);
} }
} catch { } catch {
// Continue with static fallback list below. // 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) { for (const height of candidateHeights) {
const candidateUrl = `https://${hostname}/${videoId}/play_${height}p.mp4`; const candidateUrl = `https://${hostname}/${videoId}/play_${height}p.mp4`;
try { if (await isRemoteFileAvailable(candidateUrl)) return candidateUrl;
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.
}
} }
// Last-resort fallback // Last-resort fallback
@@ -90,6 +166,76 @@ async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
return `https://${hostname}/${videoId}/play_${fallbackHeight}p.mp4`; return `https://${hostname}/${videoId}/play_${fallbackHeight}p.mp4`;
} }
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {
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<BunnyDownloadSource> {
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<BunnyDownloadSource | null> {
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 { function extractHeightFromBunnyMp4Url(url: string): number | null {
const match = url.match(/\/play_(\d+)p\.mp4$/); const match = url.match(/\/play_(\d+)p\.mp4$/);
if (!match?.[1]) return null; if (!match?.[1]) return null;
@@ -97,18 +243,85 @@ function extractHeightFromBunnyMp4Url(url: string): number | null {
return Number.isFinite(parsed) && parsed > 0 ? parsed : 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 // GET /api/versions/[versionId]/download
export async function GET(request: Request, { params }: RouteParams) { export async function GET(request: Request, { params }: RouteParams) {
try { 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; if (limited) return limited;
const session = await auth(); const session = await auth();
const { versionId } = await params; const { versionId } = await params;
const { searchParams } = new URL(request.url);
const isPrepareOnly = searchParams.get('prepare') === '1';
const requestedQuality = Number(searchParams.get('quality')); const requestedQuality = Number(searchParams.get('quality'));
const rawQuality = 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({ const version = await db.videoVersion.findUnique({
where: { id: versionId }, 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'); 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 ( if (
rawQuality !== null && rawQuality !== null &&
(!Number.isFinite(requestedQuality) || !BUNNY_ALLOWED_QUALITIES.has(requestedQuality)) (!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'); return apiErrors.badRequest('Invalid quality. Allowed values: 2160, 1440, 1080, 720, 480, 360, 240');
} }
const sourceUrl = Number.isFinite(requestedQuality) && requestedQuality > 0 if (rawQuality !== null && sourcePreference === 'original') {
? `https://${resolveBunnyCdnHostname()}/${version.videoId}/play_${requestedQuality}p.mp4` return apiErrors.badRequest('Quality cannot be used when source=original');
: await resolveHighestBunnyMp4Url(version.videoId); }
const resolvedQuality = extractHeightFromBunnyMp4Url(sourceUrl);
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) { if (isPrepareOnly) {
const response = successResponse({ const response = successResponse({
quality: resolvedQuality, quality: source.quality,
sourceType: source.sourceType,
}); });
return withCacheControl(response, 'private, no-store'); 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) { if (!upstream.ok || !upstream.body) {
return apiErrors.notFound('Download file'); return apiErrors.notFound('Download file');
} }
const versionLabel = version.versionLabel?.trim() || `v${version.versionNumber}`; 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 contentDisposition = buildContentDisposition(filename);
const response = new Response(upstream.body, { const response = new Response(upstream.body, {
status: 200, status: 200,
headers: { headers: {
'Content-Type': upstream.headers.get('content-type') || 'video/mp4', 'Content-Type': metadata.contentType,
'Content-Disposition': contentDisposition, 'Content-Disposition': contentDisposition,
'Cache-Control': 'private, no-store', 'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
}, },
}); });
+4
View File
@@ -876,6 +876,10 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<CheckSquare className="mt-0.5 h-4 w-4 text-primary" /> <CheckSquare className="mt-0.5 h-4 w-4 text-primary" />
<span>100 GB of direct video upload storage (+$5 per next 100 GB)</span> <span>100 GB of direct video upload storage (+$5 per next 100 GB)</span>
</li> </li>
<li className="flex items-start gap-2">
<CheckSquare className="mt-0.5 h-4 w-4 text-primary" />
<span>Download uploaded original files as-is</span>
</li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<CheckSquare className="mt-0.5 h-4 w-4 text-primary" /> <CheckSquare className="mt-0.5 h-4 w-4 text-primary" />
<span>Instant Webhook (Telegram/Email) Setup</span> <span>Instant Webhook (Telegram/Email) Setup</span>
+112 -13
View File
@@ -238,6 +238,8 @@ interface BunnyQualityOption {
} }
type BunnyPlaybackState = 'none' | 'processing' | 'error'; type BunnyPlaybackState = 'none' | 'processing' | 'error';
type BunnyDownloadPreference = 'original' | 'compressed';
type DownloadTarget = BunnyDownloadPreference | 'direct';
export type VideoPageMode = 'dashboard' | 'watch'; export type VideoPageMode = 'dashboard' | 'watch';
@@ -300,7 +302,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [showResolved, setShowResolved] = useState(false); const [showResolved, setShowResolved] = useState(false);
const [isExportingCsv, setIsExportingCsv] = useState(false); const [isExportingCsv, setIsExportingCsv] = useState(false);
const [isExportingPdf, setIsExportingPdf] = useState(false); const [isExportingPdf, setIsExportingPdf] = useState(false);
const [isDownloadingVideo, setIsDownloadingVideo] = useState(false); const [activeDownloadTarget, setActiveDownloadTarget] = useState<DownloadTarget | null>(null);
// Watch progress state // Watch progress state
const [savedProgress, setSavedProgress] = useState<number | null>(null); const [savedProgress, setSavedProgress] = useState<number | null>(null);
@@ -542,6 +544,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
video?.versions?.[0]; video?.versions?.[0];
}, [video?.versions, activeVersionId]); }, [video?.versions, activeVersionId]);
const isDownloadingVideo = activeDownloadTarget !== null;
const isVideoDownloadAvailable = useMemo(() => { const isVideoDownloadAvailable = useMemo(() => {
if (!activeVersion) return false; if (!activeVersion) return false;
if (activeVersion.providerId === 'bunny') return true; if (activeVersion.providerId === 'bunny') return true;
@@ -549,31 +553,35 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
return !!getSafeDirectDownloadUrl(activeVersion.originalUrl); return !!getSafeDirectDownloadUrl(activeVersion.originalUrl);
}, [activeVersion]); }, [activeVersion]);
const handleDownloadVideo = useCallback(async () => { const handleDownloadVideo = useCallback(async (preference: BunnyDownloadPreference = 'compressed') => {
if (!activeVersion || !video || isDownloadingVideo) return; if (!activeVersion || !video || isDownloadingVideo) return;
if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') { if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') {
toast.error('This video source does not support direct download'); toast.error('This video source does not support direct download');
return; return;
} }
setIsDownloadingVideo(true); const target: DownloadTarget = activeVersion.providerId === 'bunny' ? preference : 'direct';
setActiveDownloadTarget(target);
try { try {
let downloadUrl: string | null = null; let downloadUrl: string | null = null;
if (activeVersion.providerId === 'bunny') { 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', cache: 'no-store',
}); });
if (!prepareRes.ok) { 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); downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`;
const quality = Number(prepareBody?.data?.quality);
const qualityQuery = Number.isFinite(quality) && quality > 0
? `?quality=${quality}`
: '';
downloadUrl = `/api/versions/${activeVersion.id}/download${qualityQuery}`;
} else { } else {
downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl); downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl);
if (!downloadUrl) { if (!downloadUrl) {
@@ -589,7 +597,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video'; const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video';
const a = document.createElement('a'); const a = document.createElement('a');
a.href = downloadUrl; a.href = downloadUrl;
if (activeVersion.providerId === 'direct') {
a.download = `${baseName}.mp4`; a.download = `${baseName}.mp4`;
}
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
a.remove(); a.remove();
@@ -597,11 +607,13 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
console.error('Failed to start video download:', error); console.error('Failed to start video download:', error);
if (error instanceof Error && error.message === 'Direct download URL is not allowed') { if (error instanceof Error && error.message === 'Direct download URL is not allowed') {
toast.error('This direct download host is not allowed'); toast.error('This direct download host is not allowed');
} else if (error instanceof Error && error.message) {
toast.error(error.message);
} else { } else {
toast.error('Failed to start download'); toast.error('Failed to start download');
} }
} finally { } finally {
setIsDownloadingVideo(false); setActiveDownloadTarget(null);
} }
}, [activeVersion, isDownloadingVideo, video]); }, [activeVersion, isDownloadingVideo, video]);
@@ -2800,6 +2812,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{activeVersion?.providerId === 'bunny' ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -2807,7 +2822,57 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
'transition-opacity duration-300', 'transition-opacity duration-300',
isDownloadingVideo && 'opacity-50 pointer-events-none' isDownloadingVideo && 'opacity-50 pointer-events-none'
)} )}
onClick={handleDownloadVideo} disabled={!isVideoDownloadAvailable || isDownloadingVideo}
>
{isDownloadingVideo ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Download className="h-4 w-4 mr-1" />
)}
Download
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
void handleDownloadVideo('original');
}}
disabled={!isVideoDownloadAvailable || isDownloadingVideo}
>
{activeDownloadTarget === 'original' ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download Original
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
void handleDownloadVideo('compressed');
}}
disabled={!isVideoDownloadAvailable || isDownloadingVideo}
>
{activeDownloadTarget === 'compressed' ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download Compressed
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
variant="outline"
size="sm"
className={cn(
'transition-opacity duration-300',
isDownloadingVideo && 'opacity-50 pointer-events-none'
)}
onClick={() => void handleDownloadVideo()}
disabled={!isVideoDownloadAvailable || isDownloadingVideo} disabled={!isVideoDownloadAvailable || isDownloadingVideo}
> >
{isDownloadingVideo ? ( {isDownloadingVideo ? (
@@ -2817,6 +2882,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
)} )}
Download Download
</Button> </Button>
)}
{mode === 'dashboard' && ( {mode === 'dashboard' && (
<> <>
@@ -2961,6 +3027,38 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{activeVersion?.providerId === 'bunny' ? (
<>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
void handleDownloadVideo('original');
}}
disabled={!isVideoDownloadAvailable || isDownloadingVideo}
>
{activeDownloadTarget === 'original' ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download Original
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
void handleDownloadVideo('compressed');
}}
disabled={!isVideoDownloadAvailable || isDownloadingVideo}
>
{activeDownloadTarget === 'compressed' ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download Compressed
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem <DropdownMenuItem
onSelect={(event) => { onSelect={(event) => {
event.preventDefault(); event.preventDefault();
@@ -2975,6 +3073,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
)} )}
Download Download
</DropdownMenuItem> </DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => setShowVersionDialog(true)}> <DropdownMenuItem onSelect={() => setShowVersionDialog(true)}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
New Version New Version
+4
View File
@@ -29,6 +29,10 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
// Watch progress — allow frequent updates but prevent abuse // Watch progress — allow frequent updates but prevent abuse
'watch-progress': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute (pausing + periodic + visibility changes) '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 // Member management
'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour 'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
'manage-member': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute 'manage-member': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute