From 76d37d02e500ce4c1ea1729803cddc191ff781dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Wed, 25 Feb 2026 18:59:02 +0300 Subject: [PATCH] feat: make media cleanup best-effort with warning summaries and enforce video/workspace management access --- .../[projectId]/project-content-client.tsx | 8 +- app/api/projects/[projectId]/route.ts | 34 +++-- .../[projectId]/videos/[videoId]/route.ts | 31 +++-- .../[videoId]/versions/[versionId]/route.ts | 47 ++++--- .../[videoId]/assets/[assetId]/route.ts | 47 ++++--- app/api/workspaces/[workspaceId]/route.ts | 92 +++++++------ components/video-card.tsx | 77 ++++++----- components/video-page/video-page-header.tsx | 130 ++++++++++-------- lib/bunny-stream-cleanup.ts | 111 ++++++++++----- lib/cleanup-warnings.ts | 64 +++++++++ lib/r2-cleanup.ts | 54 ++++++-- 11 files changed, 444 insertions(+), 251 deletions(-) create mode 100644 lib/cleanup-warnings.ts 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