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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
+164
-36
@@ -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,13 +222,44 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
|
||||
return (
|
||||
<>
|
||||
<div className="relative">
|
||||
{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={`group overflow-hidden transition-colors hover:bg-accent/50 cursor-pointer ${
|
||||
isDeleting ? 'pointer-events-none opacity-70' : ''
|
||||
}`}
|
||||
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
|
||||
}
|
||||
>
|
||||
<Link href={`/projects/${projectId}/videos/${video.id}`}>
|
||||
{/* Thumbnail */}
|
||||
{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">
|
||||
@@ -218,19 +267,15 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
|
||||
<span className="text-xs text-muted-foreground font-medium">
|
||||
Processing thumbnail...
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/90">
|
||||
Video may already be playable
|
||||
</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 transition-transform group-hover:scale-105"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
onError={() => {
|
||||
setImgError(true);
|
||||
// Check again after 10 seconds in case Bunny is still processing
|
||||
setTimeout(() => {
|
||||
setRetryKey(Date.now());
|
||||
setImgError(false);
|
||||
@@ -242,37 +287,95 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
|
||||
Thumbnail unavailable
|
||||
</div>
|
||||
)}
|
||||
{!imgError && (
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Play className="h-12 w-12 text-white" fill="white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={`/projects/${projectId}/videos/${video.id}`}>
|
||||
{/* Thumbnail */}
|
||||
<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>
|
||||
<span className="text-[11px] text-muted-foreground/90">
|
||||
Video may already be playable
|
||||
</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 transition-transform group-hover:scale-105"
|
||||
onError={() => {
|
||||
setImgError(true);
|
||||
// Check again after 10 seconds in case Bunny is still processing
|
||||
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>
|
||||
)}
|
||||
{!imgError && (
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Play className="h-12 w-12 text-white" fill="white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<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">
|
||||
<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>
|
||||
{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>
|
||||
) : (
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
@@ -224,6 +224,9 @@ model Project {
|
||||
|
||||
// Visibility
|
||||
visibility ProjectVisibility @default(PRIVATE)
|
||||
|
||||
// Whether non-admin viewers may download project media
|
||||
allowDownloads Boolean @default(false)
|
||||
|
||||
// Ownership
|
||||
ownerId String
|
||||
|
||||
Reference in New Issue
Block a user