mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat: add project bulk download and bulk video delete
Add a "Download project" / "Download selected" flow that builds a server-side manifest of downloadable media, plus a selection mode with bulk delete for project videos. Gate viewer downloads behind a new project allowDownloads setting (default off, opt-in). Admins can always download; enabling on a public project allows anonymous visitors to download. Enforce the setting on every download surface (manifest, version, asset, watch, video routes) via canDownloadProjectMedia. Add rate limits for the manifest endpoint, host allowlisting for direct download URLs, and configurable file/byte caps. Closes #16 Closes #19
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
const DOWNLOAD_STAGGER_MS = 500;
|
||||
|
||||
export type ProjectDownloadManifestFile = {
|
||||
fileName: string;
|
||||
url: string;
|
||||
sizeBytes: number | null;
|
||||
};
|
||||
|
||||
export type ProjectDownloadManifest = {
|
||||
projectName: string;
|
||||
files: ProjectDownloadManifestFile[];
|
||||
totalFiles: number;
|
||||
totalBytes: string | null;
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function triggerBrowserDownload(file: ProjectDownloadManifestFile): void {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = file.url;
|
||||
anchor.rel = 'noopener';
|
||||
if (file.url.startsWith('/')) {
|
||||
anchor.download = file.fileName;
|
||||
}
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
}
|
||||
|
||||
export async function runProjectDownloadManifest(manifest: ProjectDownloadManifest): Promise<void> {
|
||||
for (let index = 0; index < manifest.files.length; index += 1) {
|
||||
triggerBrowserDownload(manifest.files[index]!);
|
||||
if (index < manifest.files.length - 1) {
|
||||
await sleep(DOWNLOAD_STAGGER_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { VideoAssetProvider } from '@prisma/client';
|
||||
import {
|
||||
extractAudioFileNameFromProxyUrl,
|
||||
extractImageFileNameFromProxyUrl,
|
||||
extractVideoFileNameFromProxyUrl,
|
||||
sanitizeAssetDisplayName,
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
function extensionFromUrl(url: string, fallback: string): string {
|
||||
const withoutQuery = url.split('?')[0] ?? url;
|
||||
const ext = withoutQuery.includes('.') ? withoutQuery.slice(withoutQuery.lastIndexOf('.')) : '';
|
||||
return ext || 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 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);
|
||||
const ext = fileName?.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.png';
|
||||
return `${stem}${ext}`;
|
||||
}
|
||||
if (asset.provider === VideoAssetProvider.R2_AUDIO) {
|
||||
const fileName = extractAudioFileNameFromProxyUrl(asset.sourceUrl);
|
||||
const ext = fileName?.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.webm';
|
||||
return `${stem}${ext}`;
|
||||
}
|
||||
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
|
||||
const fileName = extractVideoFileNameFromProxyUrl(asset.sourceUrl);
|
||||
const ext = fileName?.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.mp4';
|
||||
return `${stem}${ext}`;
|
||||
}
|
||||
if (asset.provider === VideoAssetProvider.BUNNY) {
|
||||
return `${stem}.mp4`;
|
||||
}
|
||||
|
||||
return `${stem}.bin`;
|
||||
}
|
||||
|
||||
function versionDownloadUrl(version: VersionRow): string | null {
|
||||
if (version.providerId === 'bunny' && version.videoId) {
|
||||
return `/api/versions/${version.id}/download?source=auto`;
|
||||
}
|
||||
if (version.providerId === 'r2') {
|
||||
if (version.originalUrl.startsWith('/api/upload/video/')) {
|
||||
return version.originalUrl;
|
||||
}
|
||||
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 function buildProjectDownloadManifest(
|
||||
projectName: string,
|
||||
videos: VideoRow[]
|
||||
): ProjectDownloadManifest {
|
||||
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}`;
|
||||
|
||||
for (const version of video.versions) {
|
||||
const url = versionDownloadUrl(version);
|
||||
if (!url) continue;
|
||||
|
||||
files.push({
|
||||
fileName: makeUniqueName(buildVersionFileName(videoIndex, videoTitle, version), usedNames),
|
||||
url,
|
||||
sizeBytes: bigintToSafeNumber(version.sizeBytes),
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
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)];
|
||||
}
|
||||
@@ -70,6 +70,7 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
||||
// 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
|
||||
'project-download': { windowMs: 60 * 1000, maxRequests: 3 }, // 3 per minute
|
||||
|
||||
// Email verification
|
||||
'verify-email': { windowMs: 15 * 60 * 1000, maxRequests: 20 }, // 20 per 15 min (clicked link)
|
||||
|
||||
+6
-1
@@ -5,6 +5,7 @@ import { db } from '@/lib/db';
|
||||
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { canDownloadProjectMedia } from '@/lib/project-download';
|
||||
|
||||
const IMAGE_PROXY_PREFIX = '/api/upload/image/';
|
||||
const AUDIO_PROXY_PREFIX = '/api/upload/audio/';
|
||||
@@ -29,6 +30,7 @@ export type VideoAssetAccessContext = {
|
||||
ownerId: string;
|
||||
workspaceId: string;
|
||||
visibility: string;
|
||||
allowDownloads: boolean;
|
||||
workspace: {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
@@ -147,6 +149,7 @@ export async function getVideoAssetAccessContext(
|
||||
ownerId: true,
|
||||
workspaceId: true,
|
||||
visibility: true,
|
||||
allowDownloads: true,
|
||||
workspace: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -184,7 +187,9 @@ export async function getVideoAssetAccessContext(
|
||||
const canCommentWithShare =
|
||||
shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
|
||||
const canUploadAssets = canCommentWithMembership || canCommentWithShare;
|
||||
const canDownloadAssets = !!session?.user?.id && hasViewAccess;
|
||||
const canDownloadWithMembership = canDownloadProjectMedia(video.project, access);
|
||||
const canDownloadWithShare = shareAccess.hasAccess && shareAccess.canDownload;
|
||||
const canDownloadAssets = hasViewAccess && (canDownloadWithMembership || canDownloadWithShare);
|
||||
|
||||
const viewerUserId = session?.user?.id ?? null;
|
||||
const viewerGuestIdentityId = viewerUserId ? null : getGuestIdentityFromRequest(request);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { db } from '@/lib/db';
|
||||
import { collectVideoMediaUrls, deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||
import { buildCleanupWarnings, type CleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
|
||||
type BunnyRef = {
|
||||
providerId: string;
|
||||
videoId: string;
|
||||
};
|
||||
|
||||
export async function deleteProjectVideosWithCleanup(
|
||||
projectId: string,
|
||||
videoIds: string[]
|
||||
): Promise<{
|
||||
deletedCount: number;
|
||||
cleanupWarnings: CleanupWarnings | undefined;
|
||||
cleanupInput: {
|
||||
bunny: Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>>;
|
||||
r2: Awaited<ReturnType<typeof deleteMediaFilesBestEffort>>;
|
||||
};
|
||||
}> {
|
||||
const uniqueVideoIds = [...new Set(videoIds)];
|
||||
if (uniqueVideoIds.length === 0) {
|
||||
throw new Error('EMPTY_VIDEO_IDS');
|
||||
}
|
||||
|
||||
const videos = await db.video.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
id: { in: uniqueVideoIds },
|
||||
},
|
||||
include: {
|
||||
versions: {
|
||||
select: {
|
||||
providerId: true,
|
||||
videoId: true,
|
||||
},
|
||||
},
|
||||
assets: {
|
||||
select: {
|
||||
provider: true,
|
||||
providerVideoId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (videos.length !== uniqueVideoIds.length) {
|
||||
throw new Error('VIDEO_NOT_FOUND');
|
||||
}
|
||||
|
||||
const bunnyRefs: BunnyRef[] = [];
|
||||
const mediaUrlSets = await Promise.all(videos.map((video) => collectVideoMediaUrls(video.id)));
|
||||
const mediaUrls = [...new Set(mediaUrlSets.flat())];
|
||||
|
||||
for (const video of videos) {
|
||||
bunnyRefs.push(
|
||||
...video.versions,
|
||||
...video.assets
|
||||
.filter((asset) => asset.provider === 'BUNNY' && !!asset.providerVideoId)
|
||||
.map((asset) => ({
|
||||
providerId: 'bunny',
|
||||
videoId: asset.providerVideoId as string,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
await db.video.deleteMany({
|
||||
where: {
|
||||
projectId,
|
||||
id: { in: uniqueVideoIds },
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/projects/${projectId}`);
|
||||
|
||||
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
||||
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
||||
deleteMediaFilesBestEffort(mediaUrls),
|
||||
]);
|
||||
|
||||
const cleanupInput = {
|
||||
bunny: bunnyCleanupResult,
|
||||
r2: r2CleanupResult,
|
||||
};
|
||||
|
||||
return {
|
||||
deletedCount: videos.length,
|
||||
cleanupWarnings: buildCleanupWarnings(cleanupInput),
|
||||
cleanupInput,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user