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:
@@ -182,7 +182,13 @@ export function ProjectContentClient({
|
|||||||
{localVideos.length > 0 ? (
|
{localVideos.length > 0 ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{sortedVideos.map((video) => (
|
{sortedVideos.map((video) => (
|
||||||
<VideoCard key={video.id} video={video} projectId={projectId} onDeleted={handleVideoDeleted} />
|
<VideoCard
|
||||||
|
key={video.id}
|
||||||
|
video={video}
|
||||||
|
projectId={projectId}
|
||||||
|
canManage={canEdit}
|
||||||
|
onDeleted={handleVideoDeleted}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { NextRequest } from 'next/server';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
import { cleanupProjectMediaFiles } from '@/lib/r2-cleanup';
|
import { collectProjectMediaUrls, deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||||
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';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
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');
|
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({
|
db.videoVersion.findMany({
|
||||||
where: {
|
where: {
|
||||||
video: { projectId },
|
video: { projectId },
|
||||||
@@ -178,22 +179,37 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
providerVideoId: true,
|
providerVideoId: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
collectProjectMediaUrls(projectId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await cleanupBunnyStreamVideos([
|
const bunnyRefs = [
|
||||||
...projectVersionRefs,
|
...projectVersionRefs,
|
||||||
...projectAssetRefs.map((asset) => ({
|
...projectAssetRefs.map((asset) => ({
|
||||||
providerId: 'bunny',
|
providerId: 'bunny',
|
||||||
videoId: asset.providerVideoId as string,
|
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 } });
|
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');
|
return withCacheControl(response, 'private, no-store');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting project:', error);
|
console.error('Error deleting project:', error);
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { revalidatePath } from 'next/cache';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
import { cleanupVideoMediaFiles } from '@/lib/r2-cleanup';
|
import { collectVideoMediaUrls, deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||||
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';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
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');
|
return apiErrors.forbidden('Only project owner or admin can delete videos');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete Bunny provider videos first to avoid orphaned assets.
|
const bunnyRefs = [
|
||||||
await cleanupBunnyStreamVideos([
|
|
||||||
...video.versions,
|
...video.versions,
|
||||||
...video.assets
|
...video.assets
|
||||||
.filter((asset) => asset.provider === 'BUNNY' && !!asset.providerVideoId)
|
.filter((asset) => asset.provider === 'BUNNY' && !!asset.providerVideoId)
|
||||||
@@ -243,16 +243,31 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
providerId: 'bunny',
|
providerId: 'bunny',
|
||||||
videoId: asset.providerVideoId as string,
|
videoId: asset.providerVideoId as string,
|
||||||
})),
|
})),
|
||||||
]);
|
];
|
||||||
|
|
||||||
// Clean up voice files from R2 before cascade delete removes comment rows
|
const mediaUrls = await collectVideoMediaUrls(videoId);
|
||||||
await cleanupVideoMediaFiles(videoId);
|
|
||||||
|
|
||||||
await db.video.delete({ where: { id: videoId } });
|
await db.video.delete({ where: { id: videoId } });
|
||||||
|
|
||||||
revalidatePath(`/projects/${projectId}`);
|
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');
|
return withCacheControl(response, 'private, no-store');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting video:', error);
|
console.error('Error deleting video:', error);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { NextRequest } from 'next/server';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
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';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string; versionId: string }> };
|
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;
|
const wasActive = result.version.isActive;
|
||||||
|
const bunnyRef = {
|
||||||
// Delete Bunny provider asset for this version before DB deletion.
|
|
||||||
await cleanupBunnyStreamVideos([{
|
|
||||||
providerId: result.version.providerId,
|
providerId: result.version.providerId,
|
||||||
videoId: result.version.videoId,
|
videoId: result.version.videoId,
|
||||||
}]);
|
};
|
||||||
|
|
||||||
// Delete the version (cascades to comments)
|
await db.$transaction(async (tx) => {
|
||||||
await db.videoVersion.delete({ where: { id: versionId } });
|
// 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 the deleted version was active, activate the latest remaining one.
|
||||||
if (wasActive) {
|
if (wasActive) {
|
||||||
const latestVersion = await db.videoVersion.findFirst({
|
const latestVersion = await tx.videoVersion.findFirst({
|
||||||
where: { videoParentId: videoId },
|
where: { videoParentId: videoId },
|
||||||
orderBy: { versionNumber: 'desc' },
|
orderBy: { versionNumber: 'desc' },
|
||||||
});
|
});
|
||||||
if (latestVersion) {
|
if (latestVersion) {
|
||||||
await db.videoVersion.update({
|
await tx.videoVersion.update({
|
||||||
where: { id: latestVersion.id },
|
where: { id: latestVersion.id },
|
||||||
data: { isActive: true },
|
data: { isActive: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const response = successResponse({ message: 'Version deleted' });
|
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',
|
||||||
|
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
||||||
|
});
|
||||||
return withCacheControl(response, 'private, no-store');
|
return withCacheControl(response, 'private, no-store');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting version:', error);
|
console.error('Error deleting version:', error);
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
|
||||||
import { VideoAssetProvider } from '@prisma/client';
|
import { VideoAssetProvider } from '@prisma/client';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
import { deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||||
|
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||||
import {
|
import {
|
||||||
canDeleteAssetForViewer,
|
canDeleteAssetForViewer,
|
||||||
extractImageKeyFromProxyUrl,
|
|
||||||
getVideoAssetAccessContext,
|
getVideoAssetAccessContext,
|
||||||
} from '@/lib/video-assets';
|
} 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) {
|
if (asset.provider === VideoAssetProvider.R2_IMAGE && shouldDeleteImageObject) {
|
||||||
const imageKey = extractImageKeyFromProxyUrl(asset.sourceUrl);
|
r2CleanupResult = await deleteMediaFilesBestEffort([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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let bunnyCleanupResult: Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>> | undefined;
|
||||||
if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) {
|
if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) {
|
||||||
try {
|
bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([{
|
||||||
await cleanupBunnyStreamVideos([{
|
|
||||||
providerId: 'bunny',
|
providerId: 'bunny',
|
||||||
videoId: asset.providerVideoId,
|
videoId: asset.providerVideoId,
|
||||||
}]);
|
}]);
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to cleanup Bunny asset ${asset.providerVideoId}:`, error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
return withCacheControl(response, 'private, no-store');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting video asset:', error);
|
console.error('Error deleting video asset:', error);
|
||||||
|
|||||||
@@ -1,36 +1,14 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth, checkWorkspaceAccess } from '@/lib/auth';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
import { cleanupWorkspaceMediaFiles } from '@/lib/r2-cleanup';
|
import { collectWorkspaceMediaUrls, deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||||
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';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ workspaceId: string }> };
|
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
|
// GET /api/workspaces/[workspaceId] - Get a single workspace
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
@@ -84,11 +62,11 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
return apiErrors.notFound('Workspace');
|
return apiErrors.notFound('Workspace');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
const access = await checkWorkspaceAccess(
|
||||||
const isOwner = session?.user?.id === workspace.ownerId;
|
{ id: workspace.id, ownerId: workspace.ownerId },
|
||||||
const isMember = workspace.members.some((m: { userId: string }) => m.userId === session?.user?.id);
|
session.user.id
|
||||||
|
);
|
||||||
if (!isOwner && !isMember) {
|
if (!access.hasAccess) {
|
||||||
return apiErrors.forbidden('Access denied');
|
return apiErrors.forbidden('Access denied');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +91,16 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|||||||
return apiErrors.unauthorized();
|
return apiErrors.unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isAdmin } = await checkWorkspaceAccess(workspaceId, session.user.id);
|
const workspaceAccessTarget = await db.workspace.findUnique({
|
||||||
if (!isAdmin) {
|
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');
|
return apiErrors.forbidden('Access denied');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,18 +141,20 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
return apiErrors.unauthorized();
|
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) {
|
if (!workspace) {
|
||||||
return apiErrors.notFound('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');
|
return apiErrors.forbidden('Only the workspace owner can delete it');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete Bunny provider videos first to avoid orphaned external assets.
|
const [workspaceVersionRefs, workspaceAssetRefs, mediaUrls] = await Promise.all([
|
||||||
const [workspaceVersionRefs, workspaceAssetRefs] = await Promise.all([
|
|
||||||
db.videoVersion.findMany({
|
db.videoVersion.findMany({
|
||||||
where: {
|
where: {
|
||||||
video: {
|
video: {
|
||||||
@@ -194,21 +182,37 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
providerVideoId: true,
|
providerVideoId: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
collectWorkspaceMediaUrls(workspaceId),
|
||||||
]);
|
]);
|
||||||
await cleanupBunnyStreamVideos([
|
|
||||||
|
const bunnyRefs = [
|
||||||
...workspaceVersionRefs,
|
...workspaceVersionRefs,
|
||||||
...workspaceAssetRefs.map((asset) => ({
|
...workspaceAssetRefs.map((asset) => ({
|
||||||
providerId: 'bunny',
|
providerId: 'bunny',
|
||||||
videoId: asset.providerVideoId as string,
|
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 } });
|
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');
|
return withCacheControl(response, 'private, no-store');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting workspace:', error);
|
console.error('Error deleting workspace:', error);
|
||||||
|
|||||||
@@ -59,10 +59,11 @@ interface VideoCardProps {
|
|||||||
lastUpdated: string;
|
lastUpdated: string;
|
||||||
};
|
};
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
canManage: boolean;
|
||||||
onDeleted?: (videoId: string) => void;
|
onDeleted?: (videoId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoCard({ video, projectId, onDeleted }: VideoCardProps) {
|
export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
const [retryKey, setRetryKey] = useState(0);
|
const [retryKey, setRetryKey] = useState(0);
|
||||||
@@ -242,6 +243,7 @@ export function VideoCard({ video, projectId, onDeleted }: VideoCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{canManage ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -278,6 +280,7 @@ export function VideoCard({ video, projectId, onDeleted }: VideoCardProps) {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
|||||||
onOpenApprovalRequest,
|
onOpenApprovalRequest,
|
||||||
onOpenApprovalsPanel,
|
onOpenApprovalsPanel,
|
||||||
}: VideoPageHeaderProps) {
|
}: VideoPageHeaderProps) {
|
||||||
|
const canManageVideo = canShareVideo || canRequestApproval;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50 gap-3',
|
'shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50 gap-3',
|
||||||
@@ -188,10 +190,12 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
|||||||
|
|
||||||
{mode === 'dashboard' && (
|
{mode === 'dashboard' && (
|
||||||
<>
|
<>
|
||||||
|
{canManageVideo ? (
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowVersionDialog(true)} className="hidden sm:inline-flex">
|
<Button variant="outline" size="sm" onClick={() => setShowVersionDialog(true)} className="hidden sm:inline-flex">
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
New Version
|
New Version
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={onOpenApprovalsPanel} className="hidden sm:inline-flex">
|
<Button variant="outline" size="sm" onClick={onOpenApprovalsPanel} className="hidden sm:inline-flex">
|
||||||
<ListChecks className="h-4 w-4 mr-1" />
|
<ListChecks className="h-4 w-4 mr-1" />
|
||||||
@@ -208,6 +212,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canManageVideo ? (
|
||||||
<div className="hidden">
|
<div className="hidden">
|
||||||
<VersionActionsDialog
|
<VersionActionsDialog
|
||||||
open={showVersionDialog}
|
open={showVersionDialog}
|
||||||
@@ -229,7 +234,9 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
|||||||
onCreateVersion={onCreateVersion}
|
onCreateVersion={onCreateVersion}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(canShareVideo || canRequestApproval) && (
|
||||||
<div>
|
<div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -269,6 +276,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+57
-22
@@ -1,10 +1,16 @@
|
|||||||
import { runWithConcurrency } from '@/lib/async-pool';
|
import { runWithConcurrency } from '@/lib/async-pool';
|
||||||
|
|
||||||
interface BunnyVideoRef {
|
export interface BunnyVideoRef {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
videoId: string;
|
videoId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BunnyCleanupResult {
|
||||||
|
attempted: number;
|
||||||
|
failed: number;
|
||||||
|
failedIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
const BUNNY_API_BASE = 'https://video.bunnycdn.com';
|
const BUNNY_API_BASE = 'https://video.bunnycdn.com';
|
||||||
const BUNNY_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
|
const BUNNY_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
|
||||||
const BUNNY_DELETE_CONCURRENCY = 5;
|
const BUNNY_DELETE_CONCURRENCY = 5;
|
||||||
@@ -20,25 +26,50 @@ function getBunnyConfig(): { apiKey: string; libraryId: string } {
|
|||||||
return { apiKey, libraryId };
|
return { apiKey, libraryId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupBunnyStreamVideos(videoRefs: BunnyVideoRef[]): Promise<void> {
|
function normalizeVideoId(value: string): string | null {
|
||||||
const normalizeVideoId = (value: string): string | null => {
|
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return BUNNY_VIDEO_ID_PATTERN.test(trimmed) ? trimmed : null;
|
return BUNNY_VIDEO_ID_PATTERN.test(trimmed) ? trimmed : null;
|
||||||
};
|
}
|
||||||
|
|
||||||
const bunnyVideoIds = [...new Set(
|
function getUniqueBunnyVideoIds(videoRefs: BunnyVideoRef[]): string[] {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
videoRefs
|
videoRefs
|
||||||
.filter((ref) => ref.providerId === 'bunny' && Boolean(ref.videoId))
|
.filter((ref) => ref.providerId === 'bunny' && Boolean(ref.videoId))
|
||||||
.map((ref) => normalizeVideoId(ref.videoId))
|
.map((ref) => normalizeVideoId(ref.videoId))
|
||||||
.filter((videoId): videoId is string => Boolean(videoId))
|
.filter((videoId): videoId is string => Boolean(videoId))
|
||||||
)];
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (bunnyVideoIds.length === 0) return;
|
export async function cleanupBunnyStreamVideosBestEffort(videoRefs: BunnyVideoRef[]): Promise<BunnyCleanupResult> {
|
||||||
|
const bunnyVideoIds = getUniqueBunnyVideoIds(videoRefs);
|
||||||
|
if (bunnyVideoIds.length === 0) {
|
||||||
|
return {
|
||||||
|
attempted: 0,
|
||||||
|
failed: 0,
|
||||||
|
failedIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const { apiKey, libraryId } = getBunnyConfig();
|
let apiKey: string;
|
||||||
const failures: Array<{ videoId: string; status: number; bodySnippet: string }> = [];
|
let libraryId: string;
|
||||||
|
try {
|
||||||
|
const config = getBunnyConfig();
|
||||||
|
apiKey = config.apiKey;
|
||||||
|
libraryId = config.libraryId;
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
attempted: bunnyVideoIds.length,
|
||||||
|
failed: bunnyVideoIds.length,
|
||||||
|
failedIds: bunnyVideoIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedIds = new Set<string>();
|
||||||
|
|
||||||
await runWithConcurrency(bunnyVideoIds, BUNNY_DELETE_CONCURRENCY, async (bunnyVideoId) => {
|
await runWithConcurrency(bunnyVideoIds, BUNNY_DELETE_CONCURRENCY, async (bunnyVideoId) => {
|
||||||
|
try {
|
||||||
const response = await fetch(`${BUNNY_API_BASE}/library/${libraryId}/videos/${encodeURIComponent(bunnyVideoId)}`, {
|
const response = await fetch(`${BUNNY_API_BASE}/library/${libraryId}/videos/${encodeURIComponent(bunnyVideoId)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -50,20 +81,24 @@ export async function cleanupBunnyStreamVideos(videoRefs: BunnyVideoRef[]): Prom
|
|||||||
if (response.status === 404) return;
|
if (response.status === 404) return;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.text().catch(() => '');
|
failedIds.add(bunnyVideoId);
|
||||||
failures.push({
|
}
|
||||||
videoId: bunnyVideoId,
|
} catch {
|
||||||
status: response.status,
|
failedIds.add(bunnyVideoId);
|
||||||
bodySnippet: body.slice(0, 300),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (failures.length > 0) {
|
return {
|
||||||
const preview = failures
|
attempted: bunnyVideoIds.length,
|
||||||
.slice(0, 3)
|
failed: failedIds.size,
|
||||||
.map((failure) => `${failure.videoId} (${failure.status})`)
|
failedIds: [...failedIds],
|
||||||
.join(', ');
|
};
|
||||||
throw new Error(`Bunny cleanup failed for ${failures.length} video(s): ${preview}`);
|
}
|
||||||
}
|
|
||||||
|
export async function cleanupBunnyStreamVideos(videoRefs: BunnyVideoRef[]): Promise<void> {
|
||||||
|
const result = await cleanupBunnyStreamVideosBestEffort(videoRefs);
|
||||||
|
if (result.failed === 0) return;
|
||||||
|
|
||||||
|
const preview = result.failedIds.slice(0, 3).join(', ');
|
||||||
|
throw new Error(`Bunny cleanup failed for ${result.failed} video(s): ${preview}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { BunnyCleanupResult } from '@/lib/bunny-stream-cleanup';
|
||||||
|
import type { R2CleanupResult } from '@/lib/r2-cleanup';
|
||||||
|
|
||||||
|
interface CleanupWarningSummary {
|
||||||
|
attempted: number;
|
||||||
|
failed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanupWarnings {
|
||||||
|
bunny?: CleanupWarningSummary;
|
||||||
|
r2?: CleanupWarningSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCleanupWarnings(input: {
|
||||||
|
bunny?: BunnyCleanupResult;
|
||||||
|
r2?: R2CleanupResult;
|
||||||
|
}): CleanupWarnings | undefined {
|
||||||
|
const warnings: CleanupWarnings = {};
|
||||||
|
|
||||||
|
if (input.bunny && input.bunny.failed > 0) {
|
||||||
|
warnings.bunny = {
|
||||||
|
attempted: input.bunny.attempted,
|
||||||
|
failed: input.bunny.failed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.r2 && input.r2.failed > 0) {
|
||||||
|
warnings.r2 = {
|
||||||
|
attempted: input.r2.attempted,
|
||||||
|
failed: input.r2.failed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(warnings).length > 0 ? warnings : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logCleanupWarnings(
|
||||||
|
context: { entityType: string; entityId: string },
|
||||||
|
input: { bunny?: BunnyCleanupResult; r2?: R2CleanupResult }
|
||||||
|
): void {
|
||||||
|
if (input.bunny && input.bunny.failed > 0) {
|
||||||
|
console.error('External cleanup warning', {
|
||||||
|
entityType: context.entityType,
|
||||||
|
entityId: context.entityId,
|
||||||
|
provider: 'bunny',
|
||||||
|
operation: 'delete',
|
||||||
|
attempted: input.bunny.attempted,
|
||||||
|
failed: input.bunny.failed,
|
||||||
|
failedIds: input.bunny.failedIds.slice(0, 10),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.r2 && input.r2.failed > 0) {
|
||||||
|
console.error('External cleanup warning', {
|
||||||
|
entityType: context.entityType,
|
||||||
|
entityId: context.entityId,
|
||||||
|
provider: 'r2',
|
||||||
|
operation: 'delete',
|
||||||
|
attempted: input.r2.attempted,
|
||||||
|
failed: input.r2.failed,
|
||||||
|
failedKeys: input.r2.failedKeys.slice(0, 10),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
-11
@@ -8,17 +8,25 @@ const IMAGE_PATH_PREFIX = '/api/upload/image/';
|
|||||||
/** The path prefix for audio URLs served by the upload API. */
|
/** The path prefix for audio URLs served by the upload API. */
|
||||||
const AUDIO_PATH_PREFIX = '/api/upload/audio/';
|
const AUDIO_PATH_PREFIX = '/api/upload/audio/';
|
||||||
const CLEANUP_DELETE_CONCURRENCY = 5;
|
const CLEANUP_DELETE_CONCURRENCY = 5;
|
||||||
|
const SAFE_IMAGE_PATH = /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||||
|
const SAFE_AUDIO_PATH = /^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||||
|
|
||||||
|
export interface R2CleanupResult {
|
||||||
|
attempted: number;
|
||||||
|
failed: number;
|
||||||
|
failedKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the R2 object key from a media URL.
|
* Extract the R2 object key from a media URL.
|
||||||
* Uses string parsing instead of regex to avoid ReDoS risk on untrusted input.
|
* Accept only canonical upload URLs before deriving a storage key.
|
||||||
*/
|
*/
|
||||||
function mediaUrlToKey(url: string): string | null {
|
export function mediaUrlToKey(url: string): string | null {
|
||||||
if (url.includes(AUDIO_PATH_PREFIX)) {
|
if (SAFE_AUDIO_PATH.test(url)) {
|
||||||
const filename = url.slice(url.indexOf(AUDIO_PATH_PREFIX) + AUDIO_PATH_PREFIX.length);
|
const filename = url.slice(AUDIO_PATH_PREFIX.length);
|
||||||
return filename ? `voice/${filename}` : null;
|
return filename ? `voice/${filename}` : null;
|
||||||
} else if (url.includes(IMAGE_PATH_PREFIX)) {
|
} else if (SAFE_IMAGE_PATH.test(url)) {
|
||||||
const filename = url.slice(url.indexOf(IMAGE_PATH_PREFIX) + IMAGE_PATH_PREFIX.length);
|
const filename = url.slice(IMAGE_PATH_PREFIX.length);
|
||||||
return filename ? `images/${filename}` : null;
|
return filename ? `images/${filename}` : null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -27,8 +35,25 @@ function mediaUrlToKey(url: string): string | null {
|
|||||||
/**
|
/**
|
||||||
* Delete a list of media files from R2 (best-effort, logs failures).
|
* Delete a list of media files from R2 (best-effort, logs failures).
|
||||||
*/
|
*/
|
||||||
async function deleteMediaFiles(mediaUrls: string[]) {
|
export async function deleteMediaFilesBestEffort(mediaUrls: string[]): Promise<R2CleanupResult> {
|
||||||
const mediaKeys = [...new Set(mediaUrls.map(mediaUrlToKey).filter((key): key is string => Boolean(key)))];
|
const invalidUrls: string[] = [];
|
||||||
|
const mediaKeys = [...new Set(
|
||||||
|
mediaUrls
|
||||||
|
.map((url) => {
|
||||||
|
const key = mediaUrlToKey(url);
|
||||||
|
if (!key) invalidUrls.push(url);
|
||||||
|
return key;
|
||||||
|
})
|
||||||
|
.filter((key): key is string => Boolean(key))
|
||||||
|
)];
|
||||||
|
const failedKeys = new Set<string>();
|
||||||
|
|
||||||
|
if (invalidUrls.length > 0) {
|
||||||
|
console.error('Skipping non-canonical media URLs during R2 cleanup', {
|
||||||
|
rejectedCount: invalidUrls.length,
|
||||||
|
rejectedSamples: invalidUrls.slice(0, 10),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await runWithConcurrency(mediaKeys, CLEANUP_DELETE_CONCURRENCY, async (key) => {
|
await runWithConcurrency(mediaKeys, CLEANUP_DELETE_CONCURRENCY, async (key) => {
|
||||||
try {
|
try {
|
||||||
@@ -36,9 +61,16 @@ async function deleteMediaFiles(mediaUrls: string[]) {
|
|||||||
new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key })
|
new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key })
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
failedKeys.add(key);
|
||||||
console.error(`Failed to delete media from R2 (key: ${key}):`, err);
|
console.error(`Failed to delete media from R2 (key: ${key}):`, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
attempted: mediaKeys.length,
|
||||||
|
failed: failedKeys.size,
|
||||||
|
failedKeys: [...failedKeys],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,7 +172,7 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
|
|||||||
*/
|
*/
|
||||||
export async function cleanupVideoMediaFiles(videoId: string) {
|
export async function cleanupVideoMediaFiles(videoId: string) {
|
||||||
const urls = await collectVideoMediaUrls(videoId);
|
const urls = await collectVideoMediaUrls(videoId);
|
||||||
await deleteMediaFiles(urls);
|
await deleteMediaFilesBestEffort(urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,7 +181,7 @@ export async function cleanupVideoMediaFiles(videoId: string) {
|
|||||||
*/
|
*/
|
||||||
export async function cleanupProjectMediaFiles(projectId: string) {
|
export async function cleanupProjectMediaFiles(projectId: string) {
|
||||||
const urls = await collectProjectMediaUrls(projectId);
|
const urls = await collectProjectMediaUrls(projectId);
|
||||||
await deleteMediaFiles(urls);
|
await deleteMediaFilesBestEffort(urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,5 +190,5 @@ export async function cleanupProjectMediaFiles(projectId: string) {
|
|||||||
*/
|
*/
|
||||||
export async function cleanupWorkspaceMediaFiles(workspaceId: string) {
|
export async function cleanupWorkspaceMediaFiles(workspaceId: string) {
|
||||||
const urls = await collectWorkspaceMediaUrls(workspaceId);
|
const urls = await collectWorkspaceMediaUrls(workspaceId);
|
||||||
await deleteMediaFiles(urls);
|
await deleteMediaFilesBestEffort(urls);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user