diff --git a/app/(dashboard)/projects/[projectId]/project-content-client.tsx b/app/(dashboard)/projects/[projectId]/project-content-client.tsx
index 1edf450..9695b69 100644
--- a/app/(dashboard)/projects/[projectId]/project-content-client.tsx
+++ b/app/(dashboard)/projects/[projectId]/project-content-client.tsx
@@ -182,7 +182,13 @@ export function ProjectContentClient({
{localVideos.length > 0 ? (
{sortedVideos.map((video) => (
-
+
))}
) : (
diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts
index 9996845..fe106ec 100644
--- a/app/api/projects/[projectId]/route.ts
+++ b/app/api/projects/[projectId]/route.ts
@@ -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);
diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts
index 5937912..e416b22 100644
--- a/app/api/projects/[projectId]/videos/[videoId]/route.ts
+++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts
@@ -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);
diff --git a/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts
index 38e2ab3..85fdb7c 100644
--- a/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts
+++ b/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts
@@ -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);
diff --git a/app/api/videos/[videoId]/assets/[assetId]/route.ts b/app/api/videos/[videoId]/assets/[assetId]/route.ts
index 3c655da..7271acf 100644
--- a/app/api/videos/[videoId]/assets/[assetId]/route.ts
+++ b/app/api/videos/[videoId]/assets/[assetId]/route.ts
@@ -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> | 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> | 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);
diff --git a/app/api/workspaces/[workspaceId]/route.ts b/app/api/workspaces/[workspaceId]/route.ts
index be418ca..7df00fa 100644
--- a/app/api/workspaces/[workspaceId]/route.ts
+++ b/app/api/workspaces/[workspaceId]/route.ts
@@ -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);
diff --git a/components/video-card.tsx b/components/video-card.tsx
index 27c6228..e785f77 100644
--- a/components/video-card.tsx
+++ b/components/video-card.tsx
@@ -59,10 +59,11 @@ interface VideoCardProps {
lastUpdated: string;
};
projectId: string;
+ canManage: boolean;
onDeleted?: (videoId: string) => void;
}
-export function VideoCard({ video, projectId, onDeleted }: VideoCardProps) {
+export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardProps) {
const router = useRouter();
const [imgError, setImgError] = useState(false);
const [retryKey, setRetryKey] = useState(0);
@@ -242,42 +243,44 @@ export function VideoCard({ video, projectId, onDeleted }: VideoCardProps) {
-
-
-
-
-
-
-
-
- Share
-
-
- setShowEditDialog(true)}>
-
- Edit
-
- setShowVersionDialog(true)}>
-
- Add Version
-
- setShowDeleteDialog(true)}
- >
-
- Delete
-
-
-
+ {canManage ? (
+
+
+
+
+
+
+
+
+ Share
+
+
+ setShowEditDialog(true)}>
+
+ Edit
+
+ setShowVersionDialog(true)}>
+
+ Add Version
+
+ setShowDeleteDialog(true)}
+ >
+
+ Delete
+
+
+
+ ) : null}
diff --git a/components/video-page/video-page-header.tsx b/components/video-page/video-page-header.tsx
index 3b536f2..9c0d376 100644
--- a/components/video-page/video-page-header.tsx
+++ b/components/video-page/video-page-header.tsx
@@ -113,6 +113,8 @@ export const VideoPageHeader = memo(function VideoPageHeader({
onOpenApprovalRequest,
onOpenApprovalsPanel,
}: VideoPageHeaderProps) {
+ const canManageVideo = canShareVideo || canRequestApproval;
+
return (
-
+ {canManageVideo ? (
+
+ ) : null}
)}
-
-
-
+ {canManageVideo ? (
+
+
+
+ ) : null}
-
-
-
-
-
-
- {canShareVideo ? (
-
-
+ {(canShareVideo || canRequestApproval) && (
+
+
+
+
+
+
+ {canShareVideo ? (
+
+
+
+ Share Video
+
+
+ ) : (
+
Share Video
-
+
+ )}
+
+
+ Request Approval
- ) : (
-
-
- Share Video
-
- )}
-
-
- Request Approval
-
-
-
-
-
-
+
+
+
+
+
+ )}
>
)}
diff --git a/lib/bunny-stream-cleanup.ts b/lib/bunny-stream-cleanup.ts
index 9c2d07d..2e55274 100644
--- a/lib/bunny-stream-cleanup.ts
+++ b/lib/bunny-stream-cleanup.ts
@@ -1,10 +1,16 @@
import { runWithConcurrency } from '@/lib/async-pool';
-interface BunnyVideoRef {
+export interface BunnyVideoRef {
providerId: string;
videoId: string;
}
+export interface BunnyCleanupResult {
+ attempted: number;
+ failed: number;
+ failedIds: string[];
+}
+
const BUNNY_API_BASE = 'https://video.bunnycdn.com';
const BUNNY_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
const BUNNY_DELETE_CONCURRENCY = 5;
@@ -20,50 +26,79 @@ function getBunnyConfig(): { apiKey: string; libraryId: string } {
return { apiKey, libraryId };
}
-export async function cleanupBunnyStreamVideos(videoRefs: BunnyVideoRef[]): Promise {
- const normalizeVideoId = (value: string): string | null => {
- const trimmed = value.trim();
- return BUNNY_VIDEO_ID_PATTERN.test(trimmed) ? trimmed : null;
- };
+function normalizeVideoId(value: string): string | null {
+ const trimmed = value.trim();
+ return BUNNY_VIDEO_ID_PATTERN.test(trimmed) ? trimmed : null;
+}
- const bunnyVideoIds = [...new Set(
- videoRefs
- .filter((ref) => ref.providerId === 'bunny' && Boolean(ref.videoId))
- .map((ref) => normalizeVideoId(ref.videoId))
- .filter((videoId): videoId is string => Boolean(videoId))
- )];
+function getUniqueBunnyVideoIds(videoRefs: BunnyVideoRef[]): string[] {
+ return [
+ ...new Set(
+ videoRefs
+ .filter((ref) => ref.providerId === 'bunny' && Boolean(ref.videoId))
+ .map((ref) => normalizeVideoId(ref.videoId))
+ .filter((videoId): videoId is string => Boolean(videoId))
+ ),
+ ];
+}
- if (bunnyVideoIds.length === 0) return;
+export async function cleanupBunnyStreamVideosBestEffort(videoRefs: BunnyVideoRef[]): Promise {
+ const bunnyVideoIds = getUniqueBunnyVideoIds(videoRefs);
+ if (bunnyVideoIds.length === 0) {
+ return {
+ attempted: 0,
+ failed: 0,
+ failedIds: [],
+ };
+ }
- const { apiKey, libraryId } = getBunnyConfig();
- const failures: Array<{ videoId: string; status: number; bodySnippet: string }> = [];
+ let apiKey: 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();
await runWithConcurrency(bunnyVideoIds, BUNNY_DELETE_CONCURRENCY, async (bunnyVideoId) => {
- const response = await fetch(`${BUNNY_API_BASE}/library/${libraryId}/videos/${encodeURIComponent(bunnyVideoId)}`, {
- method: 'DELETE',
- headers: {
- AccessKey: apiKey,
- },
- });
-
- // Treat not-found as already deleted.
- if (response.status === 404) return;
-
- if (!response.ok) {
- const body = await response.text().catch(() => '');
- failures.push({
- videoId: bunnyVideoId,
- status: response.status,
- bodySnippet: body.slice(0, 300),
+ try {
+ const response = await fetch(`${BUNNY_API_BASE}/library/${libraryId}/videos/${encodeURIComponent(bunnyVideoId)}`, {
+ method: 'DELETE',
+ headers: {
+ AccessKey: apiKey,
+ },
});
+
+ // Treat not-found as already deleted.
+ if (response.status === 404) return;
+
+ if (!response.ok) {
+ failedIds.add(bunnyVideoId);
+ }
+ } catch {
+ failedIds.add(bunnyVideoId);
}
});
- if (failures.length > 0) {
- const preview = failures
- .slice(0, 3)
- .map((failure) => `${failure.videoId} (${failure.status})`)
- .join(', ');
- throw new Error(`Bunny cleanup failed for ${failures.length} video(s): ${preview}`);
- }
+ return {
+ attempted: bunnyVideoIds.length,
+ failed: failedIds.size,
+ failedIds: [...failedIds],
+ };
+}
+
+export async function cleanupBunnyStreamVideos(videoRefs: BunnyVideoRef[]): Promise {
+ 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}`);
}
diff --git a/lib/cleanup-warnings.ts b/lib/cleanup-warnings.ts
new file mode 100644
index 0000000..e35b5a3
--- /dev/null
+++ b/lib/cleanup-warnings.ts
@@ -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),
+ });
+ }
+}
diff --git a/lib/r2-cleanup.ts b/lib/r2-cleanup.ts
index 5393cd9..c4ce5ac 100644
--- a/lib/r2-cleanup.ts
+++ b/lib/r2-cleanup.ts
@@ -8,17 +8,25 @@ const IMAGE_PATH_PREFIX = '/api/upload/image/';
/** The path prefix for audio URLs served by the upload API. */
const AUDIO_PATH_PREFIX = '/api/upload/audio/';
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.
- * 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 {
- if (url.includes(AUDIO_PATH_PREFIX)) {
- const filename = url.slice(url.indexOf(AUDIO_PATH_PREFIX) + AUDIO_PATH_PREFIX.length);
+export function mediaUrlToKey(url: string): string | null {
+ if (SAFE_AUDIO_PATH.test(url)) {
+ const filename = url.slice(AUDIO_PATH_PREFIX.length);
return filename ? `voice/${filename}` : null;
- } else if (url.includes(IMAGE_PATH_PREFIX)) {
- const filename = url.slice(url.indexOf(IMAGE_PATH_PREFIX) + IMAGE_PATH_PREFIX.length);
+ } else if (SAFE_IMAGE_PATH.test(url)) {
+ const filename = url.slice(IMAGE_PATH_PREFIX.length);
return filename ? `images/${filename}` : 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).
*/
-async function deleteMediaFiles(mediaUrls: string[]) {
- const mediaKeys = [...new Set(mediaUrls.map(mediaUrlToKey).filter((key): key is string => Boolean(key)))];
+export async function deleteMediaFilesBestEffort(mediaUrls: string[]): Promise {
+ 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();
+
+ 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) => {
try {
@@ -36,9 +61,16 @@ async function deleteMediaFiles(mediaUrls: string[]) {
new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: key })
);
} catch (err) {
+ failedKeys.add(key);
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