mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-12 09:56:08 +00:00
feat(video): add secure download flow with Bunny API proxy, quality validation, rate limiting, and direct URL host allowlist
This commit is contained in:
@@ -0,0 +1,182 @@
|
|||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||||
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
type RouteParams = { params: Promise<{ versionId: 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 };
|
||||||
|
|
||||||
|
function sanitizeFileName(value: string): string {
|
||||||
|
const sanitized = value
|
||||||
|
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return sanitized.length > 0 ? sanitized : 'video';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAsciiFileName(value: string): string {
|
||||||
|
const normalized = value
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[^\x20-\x7E]/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return normalized.length > 0 ? normalized : 'video';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContentDisposition(fileNameWithExt: string): string {
|
||||||
|
const asciiFallback = toAsciiFileName(fileNameWithExt).replace(/["\\]/g, '_');
|
||||||
|
const encoded = encodeURIComponent(fileNameWithExt);
|
||||||
|
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBunnyCdnHostname(): string {
|
||||||
|
const raw = process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
|
||||||
|
if (!raw) return DEFAULT_BUNNY_CDN_HOSTNAME;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
return url.hostname || DEFAULT_BUNNY_CDN_HOSTNAME;
|
||||||
|
} catch {
|
||||||
|
return raw.replace(/^https?:\/\//, '').replace(/\/+$/, '') || DEFAULT_BUNNY_CDN_HOSTNAME;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
|
||||||
|
const hostname = resolveBunnyCdnHostname();
|
||||||
|
const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`;
|
||||||
|
|
||||||
|
let playlistHeights: number[] = [];
|
||||||
|
try {
|
||||||
|
const playlistRes = await fetch(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)
|
||||||
|
.sort((a, b) => b - a);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue with static fallback list below.
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateHeights = [...new Set([...playlistHeights, ...BUNNY_DOWNLOAD_FALLBACK_HEIGHTS])];
|
||||||
|
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last-resort fallback
|
||||||
|
const fallbackHeight = candidateHeights[0] ?? 1080;
|
||||||
|
return `https://${hostname}/${videoId}/play_${fallbackHeight}p.mp4`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHeightFromBunnyMp4Url(url: string): number | null {
|
||||||
|
const match = url.match(/\/play_(\d+)p\.mp4$/);
|
||||||
|
if (!match?.[1]) return null;
|
||||||
|
const parsed = Number(match[1]);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/versions/[versionId]/download
|
||||||
|
export async function GET(request: Request, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const limited = await rateLimit(request, 'video-download', DOWNLOAD_RATE_LIMIT);
|
||||||
|
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 version = await db.videoVersion.findUnique({
|
||||||
|
where: { id: versionId },
|
||||||
|
include: {
|
||||||
|
video: {
|
||||||
|
include: {
|
||||||
|
project: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
return apiErrors.notFound('Version');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkProjectAccess(version.video.project, session?.user?.id);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version.providerId !== 'bunny') {
|
||||||
|
return apiErrors.badRequest('Download is currently supported for Bunny versions only');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceUrl = Number.isFinite(requestedQuality) && requestedQuality > 0
|
||||||
|
? `https://${resolveBunnyCdnHostname()}/${version.videoId}/play_${requestedQuality}p.mp4`
|
||||||
|
: await resolveHighestBunnyMp4Url(version.videoId);
|
||||||
|
const resolvedQuality = extractHeightFromBunnyMp4Url(sourceUrl);
|
||||||
|
|
||||||
|
if (isPrepareOnly) {
|
||||||
|
const response = successResponse({
|
||||||
|
quality: resolvedQuality,
|
||||||
|
});
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstream = await fetch(sourceUrl, { 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 contentDisposition = buildContentDisposition(filename);
|
||||||
|
|
||||||
|
const response = new Response(upstream.body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': upstream.headers.get('content-type') || 'video/mp4',
|
||||||
|
'Content-Disposition': contentDisposition,
|
||||||
|
'Cache-Control': 'private, no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 version:', error);
|
||||||
|
return apiErrors.internalError('Failed to download video');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -185,8 +185,52 @@ function formatBunnyQualityLabel(level: { height?: number; bitrate?: number }, i
|
|||||||
return `Level ${index + 1}`;
|
return `Level ${index + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeDownloadFileName(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||||
const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
|
const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
|
||||||
|
const DIRECT_DOWNLOAD_ALLOWED_HOSTS = [
|
||||||
|
BUNNY_PULL_ZONE_HOSTNAME,
|
||||||
|
...(process.env.NEXT_PUBLIC_BUNNY_CDN_URL
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
return [new URL(process.env.NEXT_PUBLIC_BUNNY_CDN_URL).hostname];
|
||||||
|
} catch {
|
||||||
|
return [process.env.NEXT_PUBLIC_BUNNY_CDN_URL.replace(/^https?:\/\//, '').replace(/\/+$/, '')];
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: []),
|
||||||
|
...(process.env.NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS ?? '').split(','),
|
||||||
|
]
|
||||||
|
.map((host) => host.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
function getSafeDirectDownloadUrl(rawUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(rawUrl);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DIRECT_DOWNLOAD_ALLOWED_HOSTS.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedHost = parsed.hostname.toLowerCase();
|
||||||
|
if (!DIRECT_DOWNLOAD_ALLOWED_HOSTS.includes(normalizedHost)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface BunnyQualityOption {
|
interface BunnyQualityOption {
|
||||||
level: number;
|
level: number;
|
||||||
@@ -256,6 +300,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);
|
||||||
|
|
||||||
// Watch progress state
|
// Watch progress state
|
||||||
const [savedProgress, setSavedProgress] = useState<number | null>(null);
|
const [savedProgress, setSavedProgress] = useState<number | null>(null);
|
||||||
@@ -497,6 +542,69 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
video?.versions?.[0];
|
video?.versions?.[0];
|
||||||
}, [video?.versions, activeVersionId]);
|
}, [video?.versions, activeVersionId]);
|
||||||
|
|
||||||
|
const isVideoDownloadAvailable = useMemo(() => {
|
||||||
|
if (!activeVersion) return false;
|
||||||
|
if (activeVersion.providerId === 'bunny') return true;
|
||||||
|
if (activeVersion.providerId !== 'direct') return false;
|
||||||
|
return !!getSafeDirectDownloadUrl(activeVersion.originalUrl);
|
||||||
|
}, [activeVersion]);
|
||||||
|
|
||||||
|
const handleDownloadVideo = useCallback(async () => {
|
||||||
|
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);
|
||||||
|
try {
|
||||||
|
let downloadUrl: string | null = null;
|
||||||
|
|
||||||
|
if (activeVersion.providerId === 'bunny') {
|
||||||
|
const prepareRes = await fetch(`/api/versions/${activeVersion.id}/download?prepare=1`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!prepareRes.ok) {
|
||||||
|
throw new Error('Failed to prepare download');
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
} else {
|
||||||
|
downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl);
|
||||||
|
if (!downloadUrl) {
|
||||||
|
throw new Error('Direct download URL is not allowed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!downloadUrl) {
|
||||||
|
throw new Error('Missing download URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionLabel = activeVersion.versionLabel?.trim() || `v${activeVersion.versionNumber}`;
|
||||||
|
const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video';
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = downloadUrl;
|
||||||
|
a.download = `${baseName}.mp4`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
} catch (error) {
|
||||||
|
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 {
|
||||||
|
toast.error('Failed to start download');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsDownloadingVideo(false);
|
||||||
|
}
|
||||||
|
}, [activeVersion, isDownloadingVideo, video]);
|
||||||
|
|
||||||
// Memoize comments array
|
// Memoize comments array
|
||||||
const comments = useMemo(() => {
|
const comments = useMemo(() => {
|
||||||
return activeVersion?.comments || [];
|
return activeVersion?.comments || [];
|
||||||
@@ -2692,6 +2800,24 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'transition-opacity duration-300',
|
||||||
|
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
|
||||||
|
</Button>
|
||||||
|
|
||||||
{mode === 'dashboard' && (
|
{mode === 'dashboard' && (
|
||||||
<>
|
<>
|
||||||
<div className="hidden sm:flex items-center gap-2">
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
@@ -2835,6 +2961,20 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleDownloadVideo();
|
||||||
|
}}
|
||||||
|
disabled={!isVideoDownloadAvailable || isDownloadingVideo}
|
||||||
|
>
|
||||||
|
{isDownloadingVideo ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Download
|
||||||
|
</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
|
||||||
|
|||||||
Reference in New Issue
Block a user