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
+7
View File
@@ -23,6 +23,13 @@ OPENFRAME_MAX_VIDEO_UPLOAD_BYTES="5368709120"
OPENFRAME_REQUIRE_INVITE_CODE="false"
SELF_HOSTED_AUTO_CREATE_BUCKET="true"
# Project bulk-download manifest limits (GET /api/projects/[projectId]/download).
OPENFRAME_PROJECT_DOWNLOAD_MAX_FILES="250"
# 20 GiB in bytes (20 * 1024 * 1024 * 1024)
OPENFRAME_PROJECT_DOWNLOAD_MAX_BYTES="21474836480"
# Comma-separated hostnames for direct version download URLs (optional).
NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS=""
# Trusted reverse proxy mode — controls which headers getClientIp() trusts for rate limiting.
# Set this only when you have confirmed that your proxy strips/overwrites client-supplied headers.
# cloudflare — trust cf-connecting-ip (Cloudflare edge in front of the origin)
+13
View File
@@ -103,6 +103,19 @@ INVITE_CODE="your-secret-invite-code"
# Enable debug logging
# DEBUG="openframe:*"
# ============================================================================
# DOWNLOADS
# ============================================================================
# Project bulk-download manifest limits (GET /api/projects/[projectId]/download).
# Caps how many files and total known bytes a single manifest may enumerate.
OPENFRAME_PROJECT_DOWNLOAD_MAX_FILES="250"
# 20 GiB in bytes (20 * 1024 * 1024 * 1024)
OPENFRAME_PROJECT_DOWNLOAD_MAX_BYTES="21474836480"
# Comma-separated hostnames allowed for direct (non-proxied) version download URLs
# in manifests and the video page. Bunny CDN host is always allowed when configured.
# Example: "cdn.example.com,files.example.com"
NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS=""
# ============================================================================
# VIDEO PROCESSING
# ============================================================================
+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}
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import {
@@ -15,13 +15,31 @@ import {
Globe,
UserPlus,
Lock,
Download,
Loader2,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { VideoCard } from '@/components/video-card';
import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader';
import type { DirectUploadProvider } from '@/components/video-page/types';
import {
runProjectDownloadManifest,
type ProjectDownloadManifest,
} from '@/lib/client/project-download';
interface SerializedVideo {
id: string;
@@ -39,12 +57,15 @@ interface ProjectContentClientProps {
name: string;
description: string | null;
visibility: string;
allowDownloads: boolean;
workspace: { id: string; name: string } | null;
members: { role: string }[];
};
projectId: string;
videos: SerializedVideo[];
allVideoIds: string[];
canEdit: boolean;
canDownloadProject: boolean;
isOwner: boolean;
workspaceRole: string | null;
totalPages: number;
@@ -57,7 +78,9 @@ export function ProjectContentClient({
project,
projectId,
videos,
allVideoIds,
canEdit,
canDownloadProject,
isOwner,
totalPages,
currentPage,
@@ -68,11 +91,24 @@ export function ProjectContentClient({
const searchParams = useSearchParams();
const sortOrder = searchParams.get('sort') || 'desc';
const [localVideos, setLocalVideos] = useState<SerializedVideo[]>(videos);
const [selectedVideoIds, setSelectedVideoIds] = useState<string[]>([]);
const [selectionMode, setSelectionMode] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [isDeletingSelected, setIsDeletingSelected] = useState(false);
const [showDeleteSelectedDialog, setShowDeleteSelectedDialog] = useState(false);
const canSelectVideos = canDownloadProject || canEdit;
useEffect(() => {
setLocalVideos(videos);
}, [videos]);
const selectedCount = selectedVideoIds.length;
const allSelected = useMemo(
() => allVideoIds.length > 0 && selectedCount === allVideoIds.length,
[allVideoIds.length, selectedCount]
);
const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
@@ -89,8 +125,112 @@ export function ProjectContentClient({
const handleVideoDeleted = useCallback((videoId: string) => {
setLocalVideos((prev) => prev.filter((video) => video.id !== videoId));
setSelectedVideoIds((prev) => prev.filter((id) => id !== videoId));
}, []);
const toggleVideoSelection = useCallback((videoId: string, selected: boolean) => {
setSelectedVideoIds((prev) => {
if (selected) {
if (prev.includes(videoId)) return prev;
return [...prev, videoId];
}
return prev.filter((id) => id !== videoId);
});
}, []);
const handleSelectAll = useCallback(() => {
setSelectedVideoIds(allVideoIds);
}, [allVideoIds]);
const handleDeselectAll = useCallback(() => {
setSelectedVideoIds([]);
}, []);
const handleClearSelection = useCallback(() => {
setSelectedVideoIds([]);
setSelectionMode(false);
}, []);
const handleEnterSelectionMode = useCallback(() => {
setSelectionMode(true);
}, []);
const startProjectDownload = useCallback(
async (videoIds?: string[]) => {
if (!canDownloadProject || isDownloading) return;
const query =
videoIds && videoIds.length > 0
? `?videoIds=${encodeURIComponent(videoIds.join(','))}`
: '';
setIsDownloading(true);
try {
const response = await fetch(`/api/projects/${projectId}/download${query}`, {
cache: 'no-store',
});
const body = await response.json().catch(() => null);
if (!response.ok) {
const message =
typeof body?.error === 'string' ? body.error : 'Failed to prepare project download';
toast.error(message);
return;
}
const manifest = body?.data as ProjectDownloadManifest | undefined;
if (!manifest?.files?.length) {
toast.error('No downloadable files found');
return;
}
toast.info(`Starting download of ${manifest.totalFiles} files…`);
await runProjectDownloadManifest(manifest);
toast.success(`Started ${manifest.totalFiles} file downloads`);
} catch {
toast.error('Failed to start project download');
} finally {
setIsDownloading(false);
}
},
[canDownloadProject, isDownloading, projectId]
);
const handleDeleteSelected = useCallback(async () => {
if (!canEdit || selectedCount === 0 || isDeletingSelected) return;
setIsDeletingSelected(true);
try {
const response = await fetch(`/api/projects/${projectId}/videos/bulk-delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoIds: selectedVideoIds }),
});
const body = await response.json().catch(() => null);
if (!response.ok) {
const message =
typeof body?.error === 'string' ? body.error : 'Failed to delete selected videos';
toast.error(message);
return;
}
const deletedIds = new Set(selectedVideoIds);
setLocalVideos((prev) => prev.filter((video) => !deletedIds.has(video.id)));
setSelectedVideoIds([]);
setSelectionMode(false);
setShowDeleteSelectedDialog(false);
toast.success(
typeof body?.data?.message === 'string' ? body.data.message : 'Selected videos deleted'
);
router.refresh();
} catch {
toast.error('Failed to delete selected videos');
} finally {
setIsDeletingSelected(false);
}
}, [canEdit, isDeletingSelected, projectId, router, selectedCount, selectedVideoIds]);
return (
<>
<VideoDragDropUploader
@@ -131,7 +271,6 @@ export function ProjectContentClient({
</div>
<div className="flex flex-wrap items-center gap-2 mt-4 sm:mt-0">
{/* Sort Button - Left of Share */}
<Button
variant="outline"
size="sm"
@@ -153,6 +292,21 @@ export function ProjectContentClient({
</>
)}
</Button>
{canDownloadProject && localVideos.length > 0 && !selectionMode && (
<Button
variant="outline"
size="sm"
onClick={() => startProjectDownload()}
disabled={isDownloading}
>
{isDownloading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download project
</Button>
)}
{canEdit && (
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/share`}>
@@ -188,6 +342,57 @@ export function ProjectContentClient({
</div>
</div>
{selectionMode && (
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
<span className="text-sm font-medium">Selection mode</span>
<span className="text-sm text-muted-foreground">
{selectedCount > 0 ? `${selectedCount} selected` : 'None selected'}
</span>
<div className="ml-auto flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={allSelected ? handleDeselectAll : handleSelectAll}
>
{allSelected ? 'Deselect all' : 'Select all'}
</Button>
<Button variant="ghost" size="sm" onClick={handleClearSelection}>
Cancel
</Button>
{canDownloadProject && (
<Button
variant="outline"
size="sm"
onClick={() => startProjectDownload(selectedVideoIds)}
disabled={isDownloading || selectedCount === 0}
>
{isDownloading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download selected
</Button>
)}
{canEdit && (
<Button
variant="destructive"
size="sm"
onClick={() => setShowDeleteSelectedDialog(true)}
disabled={selectedCount === 0 || isDeletingSelected}
>
{isDeletingSelected ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
Delete selected
</Button>
)}
</div>
</div>
)}
{/* Videos Grid */}
{localVideos.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -197,6 +402,11 @@ export function ProjectContentClient({
video={video}
projectId={projectId}
canManage={canEdit}
canSelect={canSelectVideos}
selectionMode={selectionMode}
selected={selectedVideoIds.includes(video.id)}
onEnterSelectionMode={handleEnterSelectionMode}
onSelectedChange={(selected) => toggleVideoSelection(video.id, selected)}
onDeleted={handleVideoDeleted}
/>
))}
@@ -250,6 +460,34 @@ export function ProjectContentClient({
</Button>
</div>
)}
<AlertDialog open={showDeleteSelectedDialog} onOpenChange={setShowDeleteSelectedDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete {selectedCount} video{selectedCount === 1 ? '' : 's'}?
</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the selected videos, all of their versions, comments, and
stored media from Bunny and Cloudflare R2. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingSelected}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(event) => {
event.preventDefault();
void handleDeleteSelected();
}}
disabled={isDeletingSelected || selectedCount === 0}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeletingSelected && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Delete selected
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -85,6 +85,7 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
name: '',
description: '',
visibility: 'PRIVATE' as Visibility,
allowDownloads: false,
});
// Tag management state
@@ -109,6 +110,7 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
name: project.name || '',
description: project.description || '',
visibility: project.visibility || 'PRIVATE',
allowDownloads: project.allowDownloads ?? false,
});
}
})
@@ -351,6 +353,48 @@ export default function ProjectSettingsPageClient({ projectId }: ProjectSettings
</div>
</div>
<div className="space-y-3 rounded-xl border p-4">
<div>
<Label className="text-sm font-medium">Project downloads</Label>
<p className="text-sm text-muted-foreground mt-1">
Allow viewers to download project files. Project admins can always download.
When enabled on a public project, anyone with the link can download files
without signing in.
</p>
</div>
<button
type="button"
onClick={() =>
setFormData((prev) => ({ ...prev, allowDownloads: !prev.allowDownloads }))
}
disabled={isSaving}
className={`w-full flex items-center justify-between gap-4 p-4 rounded-xl border-2 text-left transition-all ${
formData.allowDownloads
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border hover:border-border/80 hover:bg-accent/50'
}`}
>
<div>
<div className="font-medium">Allow viewer downloads</div>
<div className="text-sm text-muted-foreground">
Public and invited viewers can download files when enabled. On public
projects this includes unauthenticated visitors.
</div>
</div>
<div
className={`shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center ${
formData.allowDownloads
? 'border-primary bg-primary'
: 'border-muted-foreground/30'
}`}
>
{formData.allowDownloads && (
<div className="w-2 h-2 rounded-full bg-primary-foreground" />
)}
</div>
</button>
</div>
{error && (
<div className="p-4 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm">
{error}
@@ -38,6 +38,7 @@ interface ProjectSharePageProps {
export default function ProjectSharePageClient({ projectId }: ProjectSharePageProps) {
const [projectName, setProjectName] = useState('');
const [projectVisibility, setProjectVisibility] = useState('');
const [allowDownloads, setAllowDownloads] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [members, setMembers] = useState<ProjectMember[]>([]);
const [copied, setCopied] = useState(false);
@@ -56,6 +57,7 @@ export default function ProjectSharePageClient({ projectId }: ProjectSharePagePr
const project = data.data;
setProjectName(project.name || '');
setProjectVisibility(project.visibility || 'PRIVATE');
setAllowDownloads(project.allowDownloads ?? false);
setMembers(project.members || []);
}
})
@@ -174,6 +176,14 @@ export default function ProjectSharePageClient({ projectId }: ProjectSharePagePr
<div className="flex-1">
<div className="font-medium">{visibilityInfo.title}</div>
<div className="text-sm opacity-80">{visibilityInfo.description}</div>
{(projectVisibility === 'PUBLIC' || projectVisibility === 'INVITE') && (
<div className="text-sm opacity-80 mt-1">
Viewer downloads:{' '}
{allowDownloads
? 'enabled (includes anonymous visitors on public links)'
: 'disabled'}
</div>
)}
</div>
<Link href={`/projects/${projectId}/settings`}>
<Button variant="ghost" size="sm" className="text-current hover:bg-current/10">
@@ -0,0 +1,107 @@
import { NextRequest } from 'next/server';
import { auth, checkProjectAccess } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { db } from '@/lib/db';
import { logError } from '@/lib/logger';
import {
buildProjectDownloadManifest,
canDownloadProjectMedia,
parseRequestedVideoIds,
validateProjectDownloadManifest,
} from '@/lib/project-download';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ projectId: string }> };
// GET /api/projects/[projectId]/download
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'project-download');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
const requestedVideoIds = parseRequestedVideoIds(request.nextUrl.searchParams.get('videoIds'));
if (requestedVideoIds && requestedVideoIds.length === 0) {
return apiErrors.badRequest('At least one video must be selected for download');
}
const project = await db.project.findUnique({
where: { id: projectId },
select: {
id: true,
name: true,
ownerId: true,
workspaceId: true,
visibility: true,
allowDownloads: true,
},
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session?.user?.id);
if (!canDownloadProjectMedia(project, access)) {
return apiErrors.forbidden('Project downloads are disabled for viewers');
}
const videos = await db.video.findMany({
where: {
projectId,
...(requestedVideoIds ? { id: { in: requestedVideoIds } } : {}),
},
orderBy: [{ position: 'asc' }, { id: 'asc' }],
select: {
id: true,
title: true,
position: true,
versions: {
orderBy: { versionNumber: 'asc' },
select: {
id: true,
versionNumber: true,
versionLabel: true,
providerId: true,
videoId: true,
originalUrl: true,
sizeBytes: true,
},
},
assets: {
orderBy: { createdAt: 'asc' },
select: {
id: true,
provider: true,
displayName: true,
sourceUrl: true,
providerVideoId: true,
sizeBytes: true,
},
},
},
});
if (requestedVideoIds) {
const foundIds = new Set(videos.map((video) => video.id));
const missing = requestedVideoIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
return apiErrors.badRequest('One or more selected videos do not belong to this project');
}
}
const manifest = buildProjectDownloadManifest(project.name, videos);
const validationError = validateProjectDownloadManifest(manifest);
if (validationError) {
return apiErrors.badRequest(validationError);
}
const response = successResponse(manifest);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error creating project download manifest:', error);
return apiErrors.internalError('Failed to prepare project download');
}
}
+5 -1
View File
@@ -110,7 +110,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
}
const body = await request.json();
const { name, description, visibility } = body;
const { name, description, visibility, allowDownloads } = body;
if (name !== undefined) {
if (typeof name !== 'string' || name.trim().length === 0) {
@@ -133,11 +133,15 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
if (visibility !== undefined && !VALID_VISIBILITY.includes(visibility)) {
return apiErrors.badRequest('Invalid visibility value');
}
if (allowDownloads !== undefined && typeof allowDownloads !== 'boolean') {
return apiErrors.badRequest('allowDownloads must be a boolean');
}
const updateData: Record<string, unknown> = {};
if (name !== undefined) updateData.name = name.trim();
if (description !== undefined) updateData.description = description?.trim() || null;
if (visibility !== undefined) updateData.visibility = visibility;
if (allowDownloads !== undefined) updateData.allowDownloads = allowDownloads;
const project = await db.project.update({
where: { id: projectId },
@@ -8,6 +8,7 @@ import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { logError } from '@/lib/logger';
import { canDownloadProjectMedia } from '@/lib/project-download';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
@@ -123,18 +124,19 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
return apiErrors.forbidden('Access denied');
}
const canDownload = canDownloadProjectMedia(video.project, access);
const response = successResponse({
...video,
isAuthenticated: !!session?.user?.id,
currentUserId: session?.user?.id || null,
currentUserName: session?.user?.name || null,
canDownload: access.hasAccess,
canDownload,
canManageTags: access.canEdit,
canResolveComments: access.canEdit,
canRequestApproval: access.canEdit,
canShareVideo: access.canEdit,
canUploadAssets: access.hasAccess,
canDownloadAssets: !!session?.user?.id && access.hasAccess,
canDownloadAssets: canDownload,
});
return withCacheControl(response, 'private, no-cache');
@@ -0,0 +1,82 @@
import { NextRequest } from 'next/server';
import { auth, checkProjectAccess } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { db } from '@/lib/db';
import { logCleanupWarnings } from '@/lib/cleanup-warnings';
import { logError } from '@/lib/logger';
import { rateLimit } from '@/lib/rate-limit';
import { deleteProjectVideosWithCleanup } from '@/lib/video-delete';
type RouteParams = { params: Promise<{ projectId: string }> };
const MAX_BULK_DELETE = 50;
// POST /api/projects/[projectId]/videos/bulk-delete
export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const project = await db.project.findUnique({
where: { id: projectId },
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session.user.id, { intent: 'manage' });
if (!access.canEdit) {
return apiErrors.forbidden('Only project owner or admin can delete videos');
}
const body = await request.json();
const { videoIds } = body as { videoIds?: unknown };
if (!Array.isArray(videoIds) || videoIds.length === 0) {
return apiErrors.badRequest('videoIds must be a non-empty array');
}
if (videoIds.length > MAX_BULK_DELETE) {
return apiErrors.badRequest(`You can delete at most ${MAX_BULK_DELETE} videos at once`);
}
if (!videoIds.every((id) => typeof id === 'string' && id.trim().length > 0)) {
return apiErrors.badRequest('Each video id must be a non-empty string');
}
const normalizedIds = [...new Set(videoIds.map((id) => id.trim()))];
let result;
try {
result = await deleteProjectVideosWithCleanup(projectId, normalizedIds);
} catch (error) {
if (error instanceof Error && error.message === 'VIDEO_NOT_FOUND') {
return apiErrors.badRequest('One or more selected videos do not belong to this project');
}
throw error;
}
if (result.cleanupWarnings) {
logCleanupWarnings(
{ entityType: 'video', entityId: `bulk:${normalizedIds.join(',')}` },
result.cleanupInput
);
}
const response = successResponse({
message: `${result.deletedCount} video${result.deletedCount === 1 ? '' : 's'} deleted`,
deletedCount: result.deletedCount,
...(result.cleanupWarnings ? { cleanupWarnings: result.cleanupWarnings } : {}),
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error bulk deleting videos:', error);
return apiErrors.internalError('Failed to delete selected videos');
}
}
@@ -8,6 +8,7 @@ import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
import { NextRequest } from 'next/server';
import { DownloadEgressSource } from '@prisma/client';
import { logError } from '@/lib/logger';
import { canDownloadProjectMedia } from '@/lib/project-download';
type RouteParams = { params: Promise<{ versionId: string }> };
type BunnyDownloadSourcePreference = 'auto' | 'original' | 'compressed';
@@ -305,7 +306,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
requiresPassword: false,
};
const canDownloadViaShareLink = shareAccess.hasAccess && shareAccess.canDownload;
if (!access.hasAccess && !canDownloadViaShareLink) {
const canDownloadViaMembership = canDownloadProjectMedia(version.video.project, access);
if (!canDownloadViaMembership && !canDownloadViaShareLink) {
return apiErrors.forbidden('Access denied');
}
@@ -85,8 +85,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
if (!context) return apiErrors.notFound('Video');
if (!context.hasViewAccess) return apiErrors.forbidden('Access denied');
if (!context.viewerUserId || !context.canDownloadAssets) {
return apiErrors.forbidden('Asset downloads require an authenticated account');
if (!context.canDownloadAssets) {
return apiErrors.forbidden('Downloads are disabled for this project');
}
const asset = await db.videoAsset.findFirst({
+6 -2
View File
@@ -5,6 +5,7 @@ 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 { canDownloadProjectMedia } from '@/lib/project-download';
import { getGuestIdentityFromRequest } from '@/lib/guest-identity';
import { logError } from '@/lib/logger';
@@ -190,10 +191,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const canCommentWithMembership = access.hasAccess;
const canCommentWithShareLink =
shareAccess.canComment && (session?.user?.id ? true : shareAccess.allowGuests);
const canDownloadWithMembership = access.hasAccess;
const canDownloadWithMembership = canDownloadProjectMedia(video.project, access);
const canDownloadWithShareLink = shareAccess.hasAccess && shareAccess.canDownload;
const canUploadAssets = canCommentWithMembership || canCommentWithShareLink;
const canDownloadAssets = !!session?.user?.id && (access.hasAccess || shareAccess.hasAccess);
const canDownloadAssets =
(access.hasAccess || shareAccess.hasAccess) &&
(canDownloadWithMembership || canDownloadWithShareLink);
const response = successResponse({
...videoData,
versions,
@@ -202,6 +205,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
name: project.name,
ownerId: project.ownerId,
visibility: project.visibility,
allowDownloads: project.allowDownloads,
},
isAuthenticated: !!session?.user?.id,
currentUserId: session?.user?.id || null,
+134 -6
View File
@@ -16,6 +16,8 @@ import {
Pencil,
Plus,
Trash2,
CheckSquare,
Check,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -53,6 +55,7 @@ import {
type VideoSource,
} from '@/lib/video-providers';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { cn } from '@/lib/utils';
interface VideoCardProps {
video: {
@@ -66,10 +69,25 @@ interface VideoCardProps {
};
projectId: string;
canManage: boolean;
canSelect?: boolean;
selectionMode?: boolean;
selected?: boolean;
onEnterSelectionMode?: () => void;
onSelectedChange?: (selected: boolean) => void;
onDeleted?: (videoId: string) => void;
}
export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardProps) {
export function VideoCard({
video,
projectId,
canManage,
canSelect = false,
selectionMode = false,
selected = false,
onEnterSelectionMode,
onSelectedChange,
onDeleted,
}: VideoCardProps) {
const router = useRouter();
const [imgError, setImgError] = useState(false);
const [retryKey, setRetryKey] = useState(0);
@@ -204,11 +222,73 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
return (
<>
<div className="relative">
<Card
className={`group overflow-hidden transition-colors hover:bg-accent/50 cursor-pointer ${
isDeleting ? 'pointer-events-none opacity-70' : ''
}`}
{selectionMode && (
<button
type="button"
role="checkbox"
aria-checked={selected}
aria-label={`Select ${video.title}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onSelectedChange?.(!selected);
}}
className={cn(
'absolute left-3 top-3 z-20 flex h-5 w-5 items-center justify-center rounded-sm border-2 transition-colors',
selected
? 'border-primary bg-primary text-primary-foreground shadow-sm'
: 'border-border bg-background/95 text-transparent shadow-sm backdrop-blur hover:border-primary/50'
)}
>
<Check className="h-3 w-3" strokeWidth={3} />
</button>
)}
<Card
className={cn(
'group overflow-hidden transition-colors',
selectionMode ? 'cursor-pointer' : 'hover:bg-accent/50 cursor-pointer',
selected && selectionMode && 'ring-2 ring-primary/40 border-primary/30',
isDeleting && 'pointer-events-none opacity-70'
)}
onClick={
selectionMode
? (event) => {
event.preventDefault();
onSelectedChange?.(!selected);
}
: undefined
}
>
{selectionMode ? (
<div className="relative aspect-video bg-muted overflow-hidden">
{imgError ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/80">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground mb-2" />
<span className="text-xs text-muted-foreground font-medium">
Processing thumbnail...
</span>
</div>
) : resolvedThumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`${resolvedThumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}`}
alt={video.title}
className="absolute inset-0 w-full h-full object-cover"
onError={() => {
setImgError(true);
setTimeout(() => {
setRetryKey(Date.now());
setImgError(false);
}, 10000);
}}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-muted/80 text-xs text-muted-foreground font-medium">
Thumbnail unavailable
</div>
)}
</div>
) : (
<Link href={`/projects/${projectId}/videos/${video.id}`}>
{/* Thumbnail */}
<div className="relative aspect-video bg-muted overflow-hidden">
@@ -249,9 +329,31 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
)}
</div>
</Link>
)}
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
{selectionMode ? (
<div className="min-w-0 flex-1">
<h3 className="font-medium truncate">{video.title}</h3>
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Badge variant="secondary" className="text-xs">
v{video.currentVersion}
</Badge>
<span className="text-xs">{video.duration}</span>
</span>
<span className="flex items-center gap-1">
<MessageSquare className="h-3.5 w-3.5" />
{video.commentCount}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{video.lastUpdated}
</span>
</div>
</div>
) : (
<Link href={`/projects/${projectId}/videos/${video.id}`} className="min-w-0 flex-1">
<h3 className="font-medium truncate">{video.title}</h3>
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
@@ -271,8 +373,9 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
</span>
</div>
</Link>
)}
{canManage ? (
{canManage && !selectionMode ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -286,6 +389,12 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canSelect && (
<DropdownMenuItem onSelect={() => onEnterSelectionMode?.()}>
<CheckSquare className="mr-2 h-4 w-4" />
Select
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href={`/projects/${projectId}/videos/${video.id}/share`}>
<Share2 className="mr-2 h-4 w-4" />
@@ -309,6 +418,25 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : canSelect && !selectionMode ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onEnterSelectionMode?.()}>
<CheckSquare className="mr-2 h-4 w-4" />
Select
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</CardContent>
+41
View File
@@ -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);
}
}
}
+289
View File
@@ -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)];
}
+1
View File
@@ -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
View File
@@ -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);
+93
View File
@@ -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,
};
}
+2 -2
View File
@@ -58,6 +58,8 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20",
@@ -68,8 +70,6 @@
"eslint": "^9",
"eslint-config-next": "16.1.6",
"eslint-config-prettier": "^10.1.5",
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"husky": "^9.1.7",
"lint-staged": "^15.5.1",
"prettier": "^3.5.3",
@@ -0,0 +1,2 @@
-- AlterTable: opt-in only — existing projects remain non-downloadable for viewers until enabled.
ALTER TABLE "projects" ADD COLUMN "allowDownloads" BOOLEAN NOT NULL DEFAULT false;
+3
View File
@@ -225,6 +225,9 @@ model Project {
// Visibility
visibility ProjectVisibility @default(PRIVATE)
// Whether non-admin viewers may download project media
allowDownloads Boolean @default(false)
// Ownership
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)