Files
OpenFrame/app/api/versions/[versionId]/download/route.ts
T

489 lines
16 KiB
TypeScript

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';
import { validateShareLinkAccess } from '@/lib/share-links';
import { getShareSessionFromRequest } from '@/lib/share-session';
import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
import { NextRequest } from 'next/server';
import { DownloadEgressSource } from '@prisma/client';
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 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 {
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 | null {
return resolveServerBunnyCdnHostname();
}
function buildBunnyOriginalUrl(videoId: string): string {
const hostname = resolveBunnyCdnHostname();
if (!hostname) return '';
return `https://${hostname}/${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> {
const hostname = resolveBunnyCdnHostname();
if (!hostname) return '';
const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`;
let playlistHeights: number[] = [];
try {
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) && 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])];
for (const height of candidateHeights) {
const candidateUrl = `https://${hostname}/${videoId}/play_${height}p.mp4`;
if (await isRemoteFileAvailable(candidateUrl)) return candidateUrl;
}
return '';
}
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {
const originalUrl = buildBunnyOriginalUrl(videoId);
if (!originalUrl) return null;
if (await isRemoteFileAvailable(originalUrl)) {
return {
sourceType: 'original',
quality: null,
url: originalUrl,
};
}
return null;
}
async function resolveBunnyCompressedSource(videoId: string, requestedQuality: number | null): Promise<BunnyDownloadSource> {
const hostname = resolveBunnyCdnHostname();
if (!hostname) {
return {
sourceType: 'compressed',
quality: null,
url: '',
};
}
if (typeof requestedQuality === 'number' && Number.isFinite(requestedQuality) && requestedQuality > 0) {
const requestedUrl = `https://${hostname}/${videoId}/play_${requestedQuality}p.mp4`;
if (await isRemoteFileAvailable(requestedUrl)) {
return {
sourceType: 'compressed',
quality: extractHeightFromBunnyMp4Url(requestedUrl),
url: requestedUrl,
};
}
}
const fallbackUrl = await resolveHighestBunnyMp4Url(videoId);
if (!fallbackUrl) {
return {
sourceType: 'compressed',
quality: null,
url: '',
};
}
return {
sourceType: 'compressed',
quality: extractHeightFromBunnyMp4Url(fallbackUrl),
url: fallbackUrl,
};
}
async function resolveBunnyDownloadSource(
videoId: string,
requestedQuality: number | null,
sourcePreference: BunnyDownloadSourcePreference
): Promise<BunnyDownloadSource | null> {
if (!resolveBunnyCdnHostname()) return 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 {
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;
}
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),
};
}
function parseEstimatedBytes(contentLengthHeader: string | null): bigint {
if (!contentLengthHeader) return BigInt(0);
const normalized = contentLengthHeader.trim();
if (!/^\d+$/.test(normalized)) return BigInt(0);
try {
const parsed = BigInt(normalized);
return parsed > BigInt(0) ? parsed : BigInt(0);
} catch {
return BigInt(0);
}
}
// GET /api/versions/[versionId]/download
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
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 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 },
include: {
video: {
include: {
project: {
include: {
workspace: {
select: {
id: true,
ownerId: true,
},
},
},
},
},
},
},
});
if (!version) {
return apiErrors.notFound('Version');
}
const access = await checkProjectAccess(version.video.project, session?.user?.id);
const shareSession = getShareSessionFromRequest(request, version.video.id);
const shareAccess = shareSession
? await validateShareLinkAccess({
token: shareSession.token,
projectId: version.video.projectId,
videoId: version.video.id,
requiredPermission: 'VIEW',
passwordVerified: shareSession.passwordVerified,
})
: { hasAccess: false, canComment: false, canDownload: false, allowGuests: false, requiresPassword: false };
const canDownloadViaShareLink = shareAccess.hasAccess && shareAccess.canDownload;
if (!access.hasAccess && !canDownloadViaShareLink) {
return apiErrors.forbidden('Access denied');
}
if (version.providerId !== 'bunny') {
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))
) {
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');
}
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: 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 versionLabel = version.versionLabel?.trim() || `v${version.versionNumber}`;
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': metadata.contentType,
'Content-Disposition': contentDisposition,
'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);
try {
await db.downloadEgressEvent.create({
data: {
versionId: version.id,
videoId: version.video.id,
projectId: version.video.project.id,
workspaceId: version.video.project.workspace.id,
billedUserId: version.video.project.workspace.ownerId,
downloaderUserId: session?.user?.id ?? null,
source: source.sourceType === 'original' ? DownloadEgressSource.ORIGINAL : DownloadEgressSource.COMPRESSED,
quality: source.quality,
estimatedBytes: parseEstimatedBytes(contentLength),
},
});
} catch (egressError) {
console.error('Failed to record download egress event:', egressError);
}
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error downloading version:', error);
return apiErrors.internalError('Failed to download video');
}
}