feat: make media cleanup best-effort with warning summaries and enforce video/workspace management access

This commit is contained in:
Yusuf İpek
2026-02-25 18:59:02 +03:00
parent 9ce033d306
commit 76d37d02e5
11 changed files with 444 additions and 251 deletions
@@ -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>
) : ( ) : (
+25 -9
View File
@@ -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) {
await db.videoVersion.update({
where: { id: latestVersion.id },
data: { isActive: true },
}); });
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'); 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);
+48 -44
View File
@@ -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);
+40 -37
View File
@@ -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,42 +243,44 @@ export function VideoCard({ video, projectId, onDeleted }: VideoCardProps) {
</div> </div>
</Link> </Link>
<DropdownMenu> {canManage ? (
<DropdownMenuTrigger asChild> <DropdownMenu>
<Button <DropdownMenuTrigger asChild>
variant="ghost" <Button
size="icon" variant="ghost"
className="h-8 w-8 shrink-0" size="icon"
onClick={(e) => e.stopPropagation()} className="h-8 w-8 shrink-0"
disabled={isDeleting} onClick={(e) => e.stopPropagation()}
> disabled={isDeleting}
<MoreVertical className="h-4 w-4" /> >
</Button> <MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger> </Button>
<DropdownMenuContent align="end"> </DropdownMenuTrigger>
<DropdownMenuItem asChild> <DropdownMenuContent align="end">
<Link href={`/projects/${projectId}/videos/${video.id}/share`}> <DropdownMenuItem asChild>
<Share2 className="mr-2 h-4 w-4" /> <Link href={`/projects/${projectId}/videos/${video.id}/share`}>
Share <Share2 className="mr-2 h-4 w-4" />
</Link> Share
</DropdownMenuItem> </Link>
<DropdownMenuItem onSelect={() => setShowEditDialog(true)}> </DropdownMenuItem>
<Pencil className="mr-2 h-4 w-4" /> <DropdownMenuItem onSelect={() => setShowEditDialog(true)}>
Edit <Pencil className="mr-2 h-4 w-4" />
</DropdownMenuItem> Edit
<DropdownMenuItem onSelect={() => setShowVersionDialog(true)}> </DropdownMenuItem>
<Plus className="mr-2 h-4 w-4" /> <DropdownMenuItem onSelect={() => setShowVersionDialog(true)}>
Add Version <Plus className="mr-2 h-4 w-4" />
</DropdownMenuItem> Add Version
<DropdownMenuItem </DropdownMenuItem>
className="text-destructive" <DropdownMenuItem
onSelect={() => setShowDeleteDialog(true)} className="text-destructive"
> onSelect={() => setShowDeleteDialog(true)}
<Trash2 className="mr-2 h-4 w-4" /> >
Delete <Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem> Delete
</DropdownMenuContent> </DropdownMenuItem>
</DropdownMenu> </DropdownMenuContent>
</DropdownMenu>
) : null}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+69 -61
View File
@@ -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' && (
<> <>
<Button variant="outline" size="sm" onClick={() => setShowVersionDialog(true)} className="hidden sm:inline-flex"> {canManageVideo ? (
<Plus className="h-4 w-4 mr-1" /> <Button variant="outline" size="sm" onClick={() => setShowVersionDialog(true)} className="hidden sm:inline-flex">
New Version <Plus className="h-4 w-4 mr-1" />
</Button> New Version
</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,67 +212,71 @@ export const VideoPageHeader = memo(function VideoPageHeader({
</Button> </Button>
)} )}
<div className="hidden"> {canManageVideo ? (
<VersionActionsDialog <div className="hidden">
open={showVersionDialog} <VersionActionsDialog
onOpenChange={setShowVersionDialog} open={showVersionDialog}
newVersionMode={newVersionMode} onOpenChange={setShowVersionDialog}
onNewVersionModeChange={setNewVersionMode} newVersionMode={newVersionMode}
newVersionUrl={newVersionUrl} onNewVersionModeChange={setNewVersionMode}
onNewVersionUrlChange={handleNewVersionUrlChange} newVersionUrl={newVersionUrl}
newVersionUrlError={newVersionUrlError} onNewVersionUrlChange={handleNewVersionUrlChange}
newVersionSource={newVersionSource} newVersionUrlError={newVersionUrlError}
newVersionFile={newVersionFile} newVersionSource={newVersionSource}
onNewVersionFileChange={setNewVersionFile} newVersionFile={newVersionFile}
newVersionLabel={newVersionLabel} onNewVersionFileChange={setNewVersionFile}
onNewVersionLabelChange={setNewVersionLabel} newVersionLabel={newVersionLabel}
newVersionUploadStatus={newVersionUploadStatus} onNewVersionLabelChange={setNewVersionLabel}
newVersionUploadProgress={newVersionUploadProgress} newVersionUploadStatus={newVersionUploadStatus}
isCreatingVersion={isCreatingVersion} newVersionUploadProgress={newVersionUploadProgress}
versionsCount={versions.length} isCreatingVersion={isCreatingVersion}
onCreateVersion={onCreateVersion} versionsCount={versions.length}
/> onCreateVersion={onCreateVersion}
</div> />
</div>
) : null}
<div> {(canShareVideo || canRequestApproval) && (
<DropdownMenu> <div>
<DropdownMenuTrigger asChild> <DropdownMenu>
<Button variant="outline" size="sm" className="w-7 px-0 self-center"> <DropdownMenuTrigger asChild>
<MoreVertical className="h-4 w-4" /> <Button variant="outline" size="sm" className="w-7 px-0 self-center">
</Button> <MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger> </Button>
<DropdownMenuContent align="end"> </DropdownMenuTrigger>
{canShareVideo ? ( <DropdownMenuContent align="end">
<DropdownMenuItem asChild> {canShareVideo ? (
<Link href={`/projects/${projectId}/videos/${videoId}/share`}> <DropdownMenuItem asChild>
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
<Share2 className="h-4 w-4 mr-2" />
Share Video
</Link>
</DropdownMenuItem>
) : (
<DropdownMenuItem disabled>
<Share2 className="h-4 w-4 mr-2" /> <Share2 className="h-4 w-4 mr-2" />
Share Video Share Video
</Link> </DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={onOpenApprovalRequest}
disabled={!canRequestApproval}
>
<ShieldCheck className="h-4 w-4 mr-2" />
Request Approval
</DropdownMenuItem> </DropdownMenuItem>
) : ( <DropdownMenuSeparator />
<DropdownMenuItem disabled> <DownloadMenuItems
<Share2 className="h-4 w-4 mr-2" /> activeVersion={activeVersion}
Share Video videoCanDownload={videoCanDownload}
</DropdownMenuItem> isDownloading={isDownloadingVideo}
)} activeDownloadTarget={activeDownloadTarget}
<DropdownMenuItem onDownload={onDownload}
onSelect={onOpenApprovalRequest} />
disabled={!canRequestApproval} </DropdownMenuContent>
> </DropdownMenu>
<ShieldCheck className="h-4 w-4 mr-2" /> </div>
Request Approval )}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DownloadMenuItems
activeVersion={activeVersion}
videoCanDownload={videoCanDownload}
isDownloading={isDownloadingVideo}
activeDownloadTarget={activeDownloadTarget}
onDownload={onDownload}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
</> </>
)} )}
</div> </div>
+73 -38
View File
@@ -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,50 +26,79 @@ 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[] {
videoRefs return [
.filter((ref) => ref.providerId === 'bunny' && Boolean(ref.videoId)) ...new Set(
.map((ref) => normalizeVideoId(ref.videoId)) videoRefs
.filter((videoId): videoId is string => Boolean(videoId)) .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<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) => {
const response = await fetch(`${BUNNY_API_BASE}/library/${libraryId}/videos/${encodeURIComponent(bunnyVideoId)}`, { try {
method: 'DELETE', const response = await fetch(`${BUNNY_API_BASE}/library/${libraryId}/videos/${encodeURIComponent(bunnyVideoId)}`, {
headers: { method: 'DELETE',
AccessKey: apiKey, 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),
}); });
// 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) { 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}`);
} }
+64
View File
@@ -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
View File
@@ -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);
} }