mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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
94 lines
2.3 KiB
TypeScript
94 lines
2.3 KiB
TypeScript
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,
|
|
};
|
|
}
|