mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: make media cleanup best-effort with warning summaries and enforce video/workspace management access
This commit is contained in:
@@ -2,8 +2,9 @@ import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupProjectMediaFiles } from '@/lib/r2-cleanup';
|
||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||
import { collectProjectMediaUrls, deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
@@ -158,7 +159,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.forbidden('Only the project owner can delete it');
|
||||
}
|
||||
|
||||
const [projectVersionRefs, projectAssetRefs] = await Promise.all([
|
||||
const [projectVersionRefs, projectAssetRefs, mediaUrls] = await Promise.all([
|
||||
db.videoVersion.findMany({
|
||||
where: {
|
||||
video: { projectId },
|
||||
@@ -178,22 +179,37 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
providerVideoId: true,
|
||||
},
|
||||
}),
|
||||
collectProjectMediaUrls(projectId),
|
||||
]);
|
||||
|
||||
await cleanupBunnyStreamVideos([
|
||||
const bunnyRefs = [
|
||||
...projectVersionRefs,
|
||||
...projectAssetRefs.map((asset) => ({
|
||||
providerId: 'bunny',
|
||||
videoId: asset.providerVideoId as string,
|
||||
})),
|
||||
]);
|
||||
|
||||
// Clean up voice files from R2 before cascade delete removes comment rows
|
||||
await cleanupProjectMediaFiles(projectId);
|
||||
];
|
||||
|
||||
await db.project.delete({ where: { id: projectId } });
|
||||
|
||||
const response = successResponse({ message: 'Project deleted' });
|
||||
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
||||
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
||||
deleteMediaFilesBestEffort(mediaUrls),
|
||||
]);
|
||||
|
||||
const cleanupInput = {
|
||||
bunny: bunnyCleanupResult,
|
||||
r2: r2CleanupResult,
|
||||
};
|
||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||
if (cleanupWarnings) {
|
||||
logCleanupWarnings({ entityType: 'project', entityId: projectId }, cleanupInput);
|
||||
}
|
||||
|
||||
const response = successResponse({
|
||||
message: 'Project deleted',
|
||||
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
console.error('Error deleting project:', error);
|
||||
|
||||
@@ -3,8 +3,9 @@ import { revalidatePath } from 'next/cache';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupVideoMediaFiles } from '@/lib/r2-cleanup';
|
||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||
import { collectVideoMediaUrls, deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
||||
@@ -234,8 +235,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.forbidden('Only project owner or admin can delete videos');
|
||||
}
|
||||
|
||||
// Delete Bunny provider videos first to avoid orphaned assets.
|
||||
await cleanupBunnyStreamVideos([
|
||||
const bunnyRefs = [
|
||||
...video.versions,
|
||||
...video.assets
|
||||
.filter((asset) => asset.provider === 'BUNNY' && !!asset.providerVideoId)
|
||||
@@ -243,16 +243,31 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
providerId: 'bunny',
|
||||
videoId: asset.providerVideoId as string,
|
||||
})),
|
||||
]);
|
||||
];
|
||||
|
||||
// Clean up voice files from R2 before cascade delete removes comment rows
|
||||
await cleanupVideoMediaFiles(videoId);
|
||||
const mediaUrls = await collectVideoMediaUrls(videoId);
|
||||
|
||||
await db.video.delete({ where: { id: videoId } });
|
||||
|
||||
revalidatePath(`/projects/${projectId}`);
|
||||
|
||||
const response = successResponse({ message: 'Video deleted' });
|
||||
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
||||
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
||||
deleteMediaFilesBestEffort(mediaUrls),
|
||||
]);
|
||||
const cleanupInput = {
|
||||
bunny: bunnyCleanupResult,
|
||||
r2: r2CleanupResult,
|
||||
};
|
||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||
if (cleanupWarnings) {
|
||||
logCleanupWarnings({ entityType: 'video', entityId: videoId }, cleanupInput);
|
||||
}
|
||||
|
||||
const response = successResponse({
|
||||
message: 'Video deleted',
|
||||
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
console.error('Error deleting video:', error);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string; versionId: string }> };
|
||||
@@ -110,31 +111,41 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const wasActive = result.version.isActive;
|
||||
|
||||
// Delete Bunny provider asset for this version before DB deletion.
|
||||
await cleanupBunnyStreamVideos([{
|
||||
const bunnyRef = {
|
||||
providerId: result.version.providerId,
|
||||
videoId: result.version.videoId,
|
||||
}]);
|
||||
};
|
||||
|
||||
// Delete the version (cascades to comments)
|
||||
await db.videoVersion.delete({ where: { id: versionId } });
|
||||
await db.$transaction(async (tx) => {
|
||||
// Delete the version (cascades to comments).
|
||||
await tx.videoVersion.delete({ where: { id: versionId } });
|
||||
|
||||
// If the deleted version was active, activate the latest remaining one
|
||||
if (wasActive) {
|
||||
const latestVersion = await db.videoVersion.findFirst({
|
||||
where: { videoParentId: videoId },
|
||||
orderBy: { versionNumber: 'desc' },
|
||||
});
|
||||
if (latestVersion) {
|
||||
await db.videoVersion.update({
|
||||
where: { id: latestVersion.id },
|
||||
data: { isActive: true },
|
||||
// If the deleted version was active, activate the latest remaining one.
|
||||
if (wasActive) {
|
||||
const latestVersion = await tx.videoVersion.findFirst({
|
||||
where: { videoParentId: videoId },
|
||||
orderBy: { versionNumber: 'desc' },
|
||||
});
|
||||
if (latestVersion) {
|
||||
await tx.videoVersion.update({
|
||||
where: { id: latestVersion.id },
|
||||
data: { isActive: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([bunnyRef]);
|
||||
const cleanupInput = { bunny: bunnyCleanupResult };
|
||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||
if (cleanupWarnings) {
|
||||
logCleanupWarnings({ entityType: 'video-version', entityId: versionId }, cleanupInput);
|
||||
}
|
||||
|
||||
const response = successResponse({ message: 'Version deleted' });
|
||||
const response = successResponse({
|
||||
message: 'Version deleted',
|
||||
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
console.error('Error deleting version:', error);
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { VideoAssetProvider } from '@prisma/client';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { db } from '@/lib/db';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||
import { deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
import {
|
||||
canDeleteAssetForViewer,
|
||||
extractImageKeyFromProxyUrl,
|
||||
getVideoAssetAccessContext,
|
||||
} from '@/lib/video-assets';
|
||||
|
||||
@@ -55,32 +54,32 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
});
|
||||
|
||||
let r2CleanupResult: Awaited<ReturnType<typeof deleteMediaFilesBestEffort>> | undefined;
|
||||
if (asset.provider === VideoAssetProvider.R2_IMAGE && shouldDeleteImageObject) {
|
||||
const imageKey = extractImageKeyFromProxyUrl(asset.sourceUrl);
|
||||
if (imageKey) {
|
||||
try {
|
||||
await r2Client.send(new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: imageKey,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete R2 image asset ${imageKey}:`, error);
|
||||
}
|
||||
}
|
||||
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
|
||||
}
|
||||
|
||||
let bunnyCleanupResult: Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>> | undefined;
|
||||
if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) {
|
||||
try {
|
||||
await cleanupBunnyStreamVideos([{
|
||||
providerId: 'bunny',
|
||||
videoId: asset.providerVideoId,
|
||||
}]);
|
||||
} catch (error) {
|
||||
console.error(`Failed to cleanup Bunny asset ${asset.providerVideoId}:`, error);
|
||||
}
|
||||
bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([{
|
||||
providerId: 'bunny',
|
||||
videoId: asset.providerVideoId,
|
||||
}]);
|
||||
}
|
||||
|
||||
const response = successResponse({ message: 'Asset deleted' });
|
||||
const cleanupInput = {
|
||||
bunny: bunnyCleanupResult,
|
||||
r2: r2CleanupResult,
|
||||
};
|
||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||
if (cleanupWarnings) {
|
||||
logCleanupWarnings({ entityType: 'video-asset', entityId: asset.id }, cleanupInput);
|
||||
}
|
||||
|
||||
const response = successResponse({
|
||||
message: 'Asset deleted',
|
||||
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
console.error('Error deleting video asset:', error);
|
||||
|
||||
@@ -1,36 +1,14 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { auth, checkWorkspaceAccess } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupWorkspaceMediaFiles } from '@/lib/r2-cleanup';
|
||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||
import { collectWorkspaceMediaUrls, deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ workspaceId: string }> };
|
||||
|
||||
// Helper to check workspace access
|
||||
async function checkWorkspaceAccess(workspaceId: string, userId: string) {
|
||||
const workspace = await db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
include: {
|
||||
members: { where: { userId } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!workspace) return { workspace: null, role: null, isOwner: false, isAdmin: false };
|
||||
|
||||
const isOwner = workspace.ownerId === userId;
|
||||
const membership = workspace.members[0];
|
||||
const role = isOwner ? 'OWNER' : membership?.role || null;
|
||||
|
||||
return {
|
||||
workspace,
|
||||
role,
|
||||
isOwner,
|
||||
isAdmin: isOwner || role === 'ADMIN',
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/workspaces/[workspaceId] - Get a single workspace
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
@@ -84,11 +62,11 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Workspace');
|
||||
}
|
||||
|
||||
// Check access
|
||||
const isOwner = session?.user?.id === workspace.ownerId;
|
||||
const isMember = workspace.members.some((m: { userId: string }) => m.userId === session?.user?.id);
|
||||
|
||||
if (!isOwner && !isMember) {
|
||||
const access = await checkWorkspaceAccess(
|
||||
{ id: workspace.id, ownerId: workspace.ownerId },
|
||||
session.user.id
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
@@ -113,8 +91,16 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
const { isAdmin } = await checkWorkspaceAccess(workspaceId, session.user.id);
|
||||
if (!isAdmin) {
|
||||
const workspaceAccessTarget = await db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: { id: true, ownerId: true },
|
||||
});
|
||||
if (!workspaceAccessTarget) {
|
||||
return apiErrors.notFound('Workspace');
|
||||
}
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceAccessTarget, session.user.id);
|
||||
if (!access.canEdit) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
@@ -155,18 +141,20 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
const { isOwner, workspace } = await checkWorkspaceAccess(workspaceId, session.user.id);
|
||||
|
||||
const workspace = await db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: { id: true, ownerId: true },
|
||||
});
|
||||
if (!workspace) {
|
||||
return apiErrors.notFound('Workspace');
|
||||
}
|
||||
|
||||
if (!isOwner) {
|
||||
const access = await checkWorkspaceAccess(workspace, session.user.id);
|
||||
if (!access.canDelete) {
|
||||
return apiErrors.forbidden('Only the workspace owner can delete it');
|
||||
}
|
||||
|
||||
// Delete Bunny provider videos first to avoid orphaned external assets.
|
||||
const [workspaceVersionRefs, workspaceAssetRefs] = await Promise.all([
|
||||
const [workspaceVersionRefs, workspaceAssetRefs, mediaUrls] = await Promise.all([
|
||||
db.videoVersion.findMany({
|
||||
where: {
|
||||
video: {
|
||||
@@ -194,21 +182,37 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
providerVideoId: true,
|
||||
},
|
||||
}),
|
||||
collectWorkspaceMediaUrls(workspaceId),
|
||||
]);
|
||||
await cleanupBunnyStreamVideos([
|
||||
|
||||
const bunnyRefs = [
|
||||
...workspaceVersionRefs,
|
||||
...workspaceAssetRefs.map((asset) => ({
|
||||
providerId: 'bunny',
|
||||
videoId: asset.providerVideoId as string,
|
||||
})),
|
||||
]);
|
||||
|
||||
// Clean up voice files from R2 before cascade delete removes comment rows
|
||||
await cleanupWorkspaceMediaFiles(workspaceId);
|
||||
];
|
||||
|
||||
await db.workspace.delete({ where: { id: workspaceId } });
|
||||
|
||||
const response = successResponse({ message: 'Workspace deleted' });
|
||||
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
||||
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
||||
deleteMediaFilesBestEffort(mediaUrls),
|
||||
]);
|
||||
|
||||
const cleanupInput = {
|
||||
bunny: bunnyCleanupResult,
|
||||
r2: r2CleanupResult,
|
||||
};
|
||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||
if (cleanupWarnings) {
|
||||
logCleanupWarnings({ entityType: 'workspace', entityId: workspaceId }, cleanupInput);
|
||||
}
|
||||
|
||||
const response = successResponse({
|
||||
message: 'Workspace deleted',
|
||||
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
console.error('Error deleting workspace:', error);
|
||||
|
||||
Reference in New Issue
Block a user