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}
@@ -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,