Files
OpenFrame/lib/project-download.ts
T
yusufipk 93e85683e9 fix(downloads): stop repeating an extension the name already carries
A voice comment's display name is the generated file name, extension
included, so appending the extension again downloaded it as
<uuid>.webm.webm. Both download paths, the single asset route and the
project zip, now append only when the name does not already end in it.
2026-07-30 23:13:04 +07:00

329 lines
11 KiB
TypeScript

import { VideoAssetProvider } from '@prisma/client';
import {
extractAudioFileNameFromProxyUrl,
extractImageFileNameFromProxyUrl,
extractVideoFileNameFromProxyUrl,
sanitizeAssetDisplayName,
withFileExtension,
} from '@/lib/video-assets';
const DEFAULT_MAX_FILES = 250;
const DEFAULT_MAX_BYTES = 20 * 1024 * 1024 * 1024; // 20 GiB
export type ProjectDownloadAccess = {
hasAccess: boolean;
canEdit: boolean;
};
export type ProjectDownloadTarget = {
id: string;
name: string;
allowDownloads: boolean;
workspaceId: string;
workspaceOwnerId: string;
};
export type ProjectDownloadManifestFile = {
fileName: string;
url: string;
sizeBytes: number | null;
};
export type ProjectDownloadManifest = {
projectName: string;
files: ProjectDownloadManifestFile[];
totalFiles: number;
totalBytes: string | null;
};
export function getProjectDownloadLimits(): { maxFiles: number; maxBytes: bigint } {
const maxFilesRaw = Number(process.env.OPENFRAME_PROJECT_DOWNLOAD_MAX_FILES ?? DEFAULT_MAX_FILES);
const maxFiles =
Number.isSafeInteger(maxFilesRaw) && maxFilesRaw > 0 ? maxFilesRaw : DEFAULT_MAX_FILES;
const maxBytesRaw = Number(process.env.OPENFRAME_PROJECT_DOWNLOAD_MAX_BYTES ?? DEFAULT_MAX_BYTES);
const maxBytes =
Number.isSafeInteger(maxBytesRaw) && maxBytesRaw > 0
? BigInt(maxBytesRaw)
: BigInt(DEFAULT_MAX_BYTES);
return { maxFiles, maxBytes };
}
export function canDownloadProjectMedia(
project: Pick<ProjectDownloadTarget, 'allowDownloads'>,
access: ProjectDownloadAccess
): boolean {
if (!access.hasAccess) return false;
if (access.canEdit) return true;
return project.allowDownloads;
}
function sanitizeFileName(value: string): string {
const sanitized = value
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
.replace(/\s+/g, ' ')
.trim();
return sanitized.length > 0 ? sanitized : 'file';
}
function getAllowedDirectHosts(): string[] {
return (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;
const allowedHosts = getAllowedDirectHosts();
if (allowedHosts.length === 0) return null;
if (!allowedHosts.includes(parsed.hostname.toLowerCase())) return null;
return parsed.toString();
} catch {
return null;
}
}
// A file extension is appended after sanitizeFileName() has run, so it has to be safe on
// its own: anything that is not a short alphanumeric run falls back. Slicing from the last
// dot of a whole URL would otherwise let `https://example.com/download` contribute
// `.com/download`, a path separator inside an archive entry name.
const SAFE_EXTENSION = /^[a-z0-9]{1,10}$/i;
function extensionFromUrl(url: string, fallback: string): string {
const withoutQuery = url.split('?')[0] ?? url;
const baseName = withoutQuery.slice(withoutQuery.lastIndexOf('/') + 1);
const dotIndex = baseName.lastIndexOf('.');
if (dotIndex <= 0) return fallback;
const ext = baseName.slice(dotIndex + 1);
return SAFE_EXTENSION.test(ext) ? `.${ext.toLowerCase()}` : fallback;
}
type VersionRow = {
id: string;
versionNumber: number;
versionLabel: string | null;
providerId: string;
videoId: string;
originalUrl: string;
sizeBytes: bigint;
};
type AssetRow = {
id: string;
provider: VideoAssetProvider;
displayName: string;
sourceUrl: string;
providerVideoId: string | null;
sizeBytes: bigint;
};
type VideoRow = {
id: string;
title: string;
position: number;
versions: VersionRow[];
assets: AssetRow[];
};
function selectLatestVersion(versions: VersionRow[]): VersionRow[] {
if (versions.length === 0) return [];
let latest = versions[0]!;
for (const version of versions) {
if (version.versionNumber > latest.versionNumber) latest = version;
}
return [latest];
}
function makeUniqueName(baseName: string, usedNames: Set<string>): string {
if (!usedNames.has(baseName)) {
usedNames.add(baseName);
return baseName;
}
const dotIndex = baseName.lastIndexOf('.');
const stem = dotIndex > 0 ? baseName.slice(0, dotIndex) : baseName;
const ext = dotIndex > 0 ? baseName.slice(dotIndex) : '';
let counter = 2;
while (usedNames.has(`${stem}-${counter}${ext}`)) {
counter += 1;
}
const unique = `${stem}-${counter}${ext}`;
usedNames.add(unique);
return unique;
}
function buildVersionFileName(videoIndex: number, videoTitle: string, version: VersionRow): string {
const label = version.versionLabel?.trim() || `v${version.versionNumber}`;
const stem = sanitizeFileName(`${String(videoIndex).padStart(2, '0')}-${videoTitle}-${label}`);
const ext = extensionFromUrl(version.originalUrl, '.mp4');
return `${stem}${ext}`;
}
function buildAssetFileName(videoIndex: number, videoTitle: string, asset: AssetRow): string {
const displayName = sanitizeAssetDisplayName(asset.displayName, 'asset');
const stem = sanitizeFileName(
`${String(videoIndex).padStart(2, '0')}-${videoTitle}-asset-${displayName}`
);
if (asset.provider === VideoAssetProvider.R2_IMAGE) {
const fileName = extractImageFileNameFromProxyUrl(asset.sourceUrl);
return withFileExtension(stem, extensionFromUrl(fileName ?? '', '.png'));
}
if (asset.provider === VideoAssetProvider.R2_AUDIO) {
const fileName = extractAudioFileNameFromProxyUrl(asset.sourceUrl);
return withFileExtension(stem, extensionFromUrl(fileName ?? '', '.webm'));
}
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
const fileName = extractVideoFileNameFromProxyUrl(asset.sourceUrl);
return withFileExtension(stem, extensionFromUrl(fileName ?? '', '.mp4'));
}
if (asset.provider === VideoAssetProvider.BUNNY) {
return withFileExtension(stem, '.mp4');
}
return withFileExtension(stem, '.bin');
}
function versionDownloadUrl(version: VersionRow): string | null {
if (version.providerId === 'bunny' && version.videoId) {
// Always fetch the original (uncompressed) file for bulk/project downloads so
// quality never drops. 'auto' could silently fall back to a compressed MP4.
return `/api/versions/${version.id}/download?source=original`;
}
if (version.providerId === 'r2') {
// Only the strict proxy-path shape is accepted. A `startsWith` check here would let
// `/api/upload/video/clip.mp4/../../../../etc/passwd` through as a download URL.
// Every r2 version is written through finalizeR2VideoUpload(), which stores exactly
// this shape, so nothing legitimate is lost.
const fileName = extractVideoFileNameFromProxyUrl(version.originalUrl);
if (fileName) return `/api/upload/video/${fileName}`;
}
if (version.providerId === 'direct') {
return getSafeDirectDownloadUrl(version.originalUrl);
}
return null;
}
function assetDownloadUrl(videoId: string, asset: AssetRow): string | null {
if (asset.provider === VideoAssetProvider.YOUTUBE) return null;
if (
asset.provider === VideoAssetProvider.R2_IMAGE ||
asset.provider === VideoAssetProvider.R2_AUDIO ||
asset.provider === VideoAssetProvider.R2_VIDEO ||
asset.provider === VideoAssetProvider.BUNNY
) {
return `/api/videos/${videoId}/assets/${asset.id}/download`;
}
return null;
}
function bigintToSafeNumber(value: bigint): number | null {
if (value <= BigInt(0)) return null;
if (value > BigInt(Number.MAX_SAFE_INTEGER)) return Number.MAX_SAFE_INTEGER;
return Number(value);
}
export type BuildProjectDownloadManifestOptions = {
/** Include every version of each video. Defaults to latest version only. */
includeAllVersions?: boolean;
/** Include b-rolls and other attached assets. Defaults to videos only. */
includeAssets?: boolean;
};
export function buildProjectDownloadManifest(
projectName: string,
videos: VideoRow[],
options: BuildProjectDownloadManifestOptions = {}
): ProjectDownloadManifest {
const { includeAllVersions = false, includeAssets = false } = options;
const files: ProjectDownloadManifestFile[] = [];
const usedNames = new Set<string>();
const sortedVideos = [...videos].sort(
(a, b) => a.position - b.position || a.id.localeCompare(b.id)
);
sortedVideos.forEach((video, index) => {
const videoIndex = index + 1;
const videoTitle = sanitizeFileName(video.title) || `video-${videoIndex}`;
const versionsToInclude = includeAllVersions
? video.versions
: selectLatestVersion(video.versions);
for (const version of versionsToInclude) {
const url = versionDownloadUrl(version);
if (!url) continue;
files.push({
fileName: makeUniqueName(buildVersionFileName(videoIndex, videoTitle, version), usedNames),
url,
sizeBytes: bigintToSafeNumber(version.sizeBytes),
});
}
if (includeAssets) {
for (const asset of video.assets) {
const url = assetDownloadUrl(video.id, asset);
if (!url) continue;
files.push({
fileName: makeUniqueName(buildAssetFileName(videoIndex, videoTitle, asset), usedNames),
url,
sizeBytes: bigintToSafeNumber(asset.sizeBytes),
});
}
}
});
const knownTotal = files.reduce((sum, file) => sum + (file.sizeBytes ?? 0), 0);
return {
projectName,
files,
totalFiles: files.length,
totalBytes: knownTotal > 0 ? String(knownTotal) : null,
};
}
export function validateProjectDownloadManifest(manifest: ProjectDownloadManifest): string | null {
if (manifest.files.length === 0) {
return 'No downloadable files found for this selection';
}
const { maxFiles, maxBytes } = getProjectDownloadLimits();
if (manifest.files.length > maxFiles) {
return `This download includes ${manifest.files.length} files, which exceeds the limit of ${maxFiles}. Try selecting fewer videos.`;
}
if (manifest.totalBytes) {
// The contract is to return a message, so a malformed total has to become one rather
// than a SyntaxError escaping into the route as a 500.
if (!/^\d+$/.test(manifest.totalBytes)) {
return 'Could not determine the size of this download';
}
const knownTotal = BigInt(manifest.totalBytes);
if (knownTotal > maxBytes) {
const maxGiB = Number(maxBytes / BigInt(1024 * 1024 * 1024));
return `This download is too large (over ${maxGiB} GiB). Try selecting fewer videos.`;
}
}
return null;
}
export function parseRequestedVideoIds(raw: string | null): string[] | null {
if (raw === null) return null;
const ids = raw
.split(',')
.map((value) => value.trim())
.filter(Boolean);
if (ids.length === 0) return [];
return [...new Set(ids)];
}