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:
yusufipk
2026-06-27 13:24:05 +02:00
parent 9613c4f2c6
commit 52e4169db2
22 changed files with 1138 additions and 50 deletions
+14 -1
View File
@@ -6,6 +6,7 @@ import { auth, checkProjectAccess } from '@/lib/auth';
import { db } from '@/lib/db';
import { ProjectContentClient } from './project-content-client';
import { isDirectFileUploadEnabled, isS3VideoUploadsEnabled } from '@/lib/feature-flags';
import { canDownloadProjectMedia } from '@/lib/project-download';
function formatDuration(seconds: number | null): string {
if (!seconds) return '0:00';
@@ -102,7 +103,7 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
}
// Fetch videos separately utilizing bounds
const [paginatedVideos, totalVideos] = await Promise.all([
const [paginatedVideos, totalVideos, allVideoIds] = await Promise.all([
db.video.findMany({
where: { projectId: project.id },
skip,
@@ -122,6 +123,11 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
db.video.count({
where: { projectId: project.id },
}),
db.video.findMany({
where: { projectId: project.id },
select: { id: true },
orderBy: [{ position: 'asc' }, { id: 'asc' }],
}),
]);
const totalPages = Math.ceil(totalVideos / pageSize);
@@ -153,10 +159,13 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
workspaceRole === 'ADMIN');
const isAuthenticated = !!session?.user?.id;
const canDownloadProject = canDownloadProjectMedia(project, access);
const projectData = {
name: project.name,
description: project.description,
visibility: project.visibility,
allowDownloads: project.allowDownloads,
workspace: project.workspace,
members: project.members,
};
@@ -180,7 +189,9 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
project={projectData}
projectId={projectId}
videos={videos}
allVideoIds={allVideoIds.map((video) => video.id)}
canEdit={false}
canDownloadProject={canDownloadProject}
isOwner={false}
workspaceRole={null}
totalPages={totalPages}
@@ -209,7 +220,9 @@ export default async function ProjectPage({ params, searchParams }: ProjectPageP
project={projectData}
projectId={projectId}
videos={videos}
allVideoIds={allVideoIds.map((video) => video.id)}
canEdit={canEdit}
canDownloadProject={canDownloadProject}
isOwner={isOwner}
workspaceRole={workspaceRole}
totalPages={totalPages}