mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user