mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +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 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{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>
|
||||
) : (
|
||||
|
||||
@@ -2,8 +2,9 @@ import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupProjectMediaFiles } from '@/lib/r2-cleanup';
|
||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||
import { collectProjectMediaUrls, deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
@@ -158,7 +159,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.forbidden('Only the project owner can delete it');
|
||||
}
|
||||
|
||||
const [projectVersionRefs, projectAssetRefs] = await Promise.all([
|
||||
const [projectVersionRefs, projectAssetRefs, mediaUrls] = await Promise.all([
|
||||
db.videoVersion.findMany({
|
||||
where: {
|
||||
video: { projectId },
|
||||
@@ -178,22 +179,37 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
providerVideoId: true,
|
||||
},
|
||||
}),
|
||||
collectProjectMediaUrls(projectId),
|
||||
]);
|
||||
|
||||
await cleanupBunnyStreamVideos([
|
||||
const bunnyRefs = [
|
||||
...projectVersionRefs,
|
||||
...projectAssetRefs.map((asset) => ({
|
||||
providerId: 'bunny',
|
||||
videoId: asset.providerVideoId as string,
|
||||
})),
|
||||
]);
|
||||
|
||||
// Clean up voice files from R2 before cascade delete removes comment rows
|
||||
await cleanupProjectMediaFiles(projectId);
|
||||
];
|
||||
|
||||
await db.project.delete({ where: { id: projectId } });
|
||||
|
||||
const response = successResponse({ message: 'Project deleted' });
|
||||
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
||||
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
||||
deleteMediaFilesBestEffort(mediaUrls),
|
||||
]);
|
||||
|
||||
const cleanupInput = {
|
||||
bunny: bunnyCleanupResult,
|
||||
r2: r2CleanupResult,
|
||||
};
|
||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||
if (cleanupWarnings) {
|
||||
logCleanupWarnings({ entityType: 'project', entityId: projectId }, cleanupInput);
|
||||
}
|
||||
|
||||
const response = successResponse({
|
||||
message: 'Project deleted',
|
||||
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
console.error('Error deleting project:', error);
|
||||
|
||||
@@ -3,8 +3,9 @@ import { revalidatePath } from 'next/cache';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupVideoMediaFiles } from '@/lib/r2-cleanup';
|
||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||
import { collectVideoMediaUrls, deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
||||
@@ -234,8 +235,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.forbidden('Only project owner or admin can delete videos');
|
||||
}
|
||||
|
||||
// Delete Bunny provider videos first to avoid orphaned assets.
|
||||
await cleanupBunnyStreamVideos([
|
||||
const bunnyRefs = [
|
||||
...video.versions,
|
||||
...video.assets
|
||||
.filter((asset) => asset.provider === 'BUNNY' && !!asset.providerVideoId)
|
||||
@@ -243,16 +243,31 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
providerId: 'bunny',
|
||||
videoId: asset.providerVideoId as string,
|
||||
})),
|
||||
]);
|
||||
];
|
||||
|
||||
// Clean up voice files from R2 before cascade delete removes comment rows
|
||||
await cleanupVideoMediaFiles(videoId);
|
||||
const mediaUrls = await collectVideoMediaUrls(videoId);
|
||||
|
||||
await db.video.delete({ where: { id: videoId } });
|
||||
|
||||
revalidatePath(`/projects/${projectId}`);
|
||||
|
||||
const response = successResponse({ message: 'Video deleted' });
|
||||
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
||||
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
||||
deleteMediaFilesBestEffort(mediaUrls),
|
||||
]);
|
||||
const cleanupInput = {
|
||||
bunny: bunnyCleanupResult,
|
||||
r2: r2CleanupResult,
|
||||
};
|
||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||
if (cleanupWarnings) {
|
||||
logCleanupWarnings({ entityType: 'video', entityId: videoId }, cleanupInput);
|
||||
}
|
||||
|
||||
const response = successResponse({
|
||||
message: 'Video deleted',
|
||||
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
console.error('Error deleting video:', error);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string; versionId: string }> };
|
||||
@@ -110,31 +111,41 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const wasActive = result.version.isActive;
|
||||
|
||||
// Delete Bunny provider asset for this version before DB deletion.
|
||||
await cleanupBunnyStreamVideos([{
|
||||
const bunnyRef = {
|
||||
providerId: result.version.providerId,
|
||||
videoId: result.version.videoId,
|
||||
}]);
|
||||
};
|
||||
|
||||
// Delete the version (cascades to comments)
|
||||
await db.videoVersion.delete({ where: { id: versionId } });
|
||||
await db.$transaction(async (tx) => {
|
||||
// Delete the version (cascades to comments).
|
||||
await tx.videoVersion.delete({ where: { id: versionId } });
|
||||
|
||||
// If the deleted version was active, activate the latest remaining one
|
||||
// If the deleted version was active, activate the latest remaining one.
|
||||
if (wasActive) {
|
||||
const latestVersion = await db.videoVersion.findFirst({
|
||||
const latestVersion = await tx.videoVersion.findFirst({
|
||||
where: { videoParentId: videoId },
|
||||
orderBy: { versionNumber: 'desc' },
|
||||
});
|
||||
if (latestVersion) {
|
||||
await db.videoVersion.update({
|
||||
await tx.videoVersion.update({
|
||||
where: { id: latestVersion.id },
|
||||
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');
|
||||
} catch (error) {
|
||||
console.error('Error deleting version:', error);
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { VideoAssetProvider } from '@prisma/client';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { db } from '@/lib/db';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||
import { deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
import {
|
||||
canDeleteAssetForViewer,
|
||||
extractImageKeyFromProxyUrl,
|
||||
getVideoAssetAccessContext,
|
||||
} from '@/lib/video-assets';
|
||||
|
||||
@@ -55,32 +54,32 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
});
|
||||
|
||||
let r2CleanupResult: Awaited<ReturnType<typeof deleteMediaFilesBestEffort>> | undefined;
|
||||
if (asset.provider === VideoAssetProvider.R2_IMAGE && shouldDeleteImageObject) {
|
||||
const imageKey = extractImageKeyFromProxyUrl(asset.sourceUrl);
|
||||
if (imageKey) {
|
||||
try {
|
||||
await r2Client.send(new DeleteObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: imageKey,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete R2 image asset ${imageKey}:`, error);
|
||||
}
|
||||
}
|
||||
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
|
||||
}
|
||||
|
||||
let bunnyCleanupResult: Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>> | undefined;
|
||||
if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) {
|
||||
try {
|
||||
await cleanupBunnyStreamVideos([{
|
||||
bunnyCleanupResult = await cleanupBunnyStreamVideosBestEffort([{
|
||||
providerId: 'bunny',
|
||||
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');
|
||||
} catch (error) {
|
||||
console.error('Error deleting video asset:', error);
|
||||
|
||||
@@ -1,36 +1,14 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { auth, checkWorkspaceAccess } from '@/lib/auth';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { cleanupWorkspaceMediaFiles } from '@/lib/r2-cleanup';
|
||||
import { cleanupBunnyStreamVideos } from '@/lib/bunny-stream-cleanup';
|
||||
import { collectWorkspaceMediaUrls, deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
||||
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
||||
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
|
||||
type RouteParams = { params: Promise<{ workspaceId: string }> };
|
||||
|
||||
// Helper to check workspace access
|
||||
async function checkWorkspaceAccess(workspaceId: string, userId: string) {
|
||||
const workspace = await db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
include: {
|
||||
members: { where: { userId } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!workspace) return { workspace: null, role: null, isOwner: false, isAdmin: false };
|
||||
|
||||
const isOwner = workspace.ownerId === userId;
|
||||
const membership = workspace.members[0];
|
||||
const role = isOwner ? 'OWNER' : membership?.role || null;
|
||||
|
||||
return {
|
||||
workspace,
|
||||
role,
|
||||
isOwner,
|
||||
isAdmin: isOwner || role === 'ADMIN',
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/workspaces/[workspaceId] - Get a single workspace
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
@@ -84,11 +62,11 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.notFound('Workspace');
|
||||
}
|
||||
|
||||
// Check access
|
||||
const isOwner = session?.user?.id === workspace.ownerId;
|
||||
const isMember = workspace.members.some((m: { userId: string }) => m.userId === session?.user?.id);
|
||||
|
||||
if (!isOwner && !isMember) {
|
||||
const access = await checkWorkspaceAccess(
|
||||
{ id: workspace.id, ownerId: workspace.ownerId },
|
||||
session.user.id
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
@@ -113,8 +91,16 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
const { isAdmin } = await checkWorkspaceAccess(workspaceId, session.user.id);
|
||||
if (!isAdmin) {
|
||||
const workspaceAccessTarget = await db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: { id: true, ownerId: true },
|
||||
});
|
||||
if (!workspaceAccessTarget) {
|
||||
return apiErrors.notFound('Workspace');
|
||||
}
|
||||
|
||||
const access = await checkWorkspaceAccess(workspaceAccessTarget, session.user.id);
|
||||
if (!access.canEdit) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
@@ -155,18 +141,20 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
const { isOwner, workspace } = await checkWorkspaceAccess(workspaceId, session.user.id);
|
||||
|
||||
const workspace = await db.workspace.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: { id: true, ownerId: true },
|
||||
});
|
||||
if (!workspace) {
|
||||
return apiErrors.notFound('Workspace');
|
||||
}
|
||||
|
||||
if (!isOwner) {
|
||||
const access = await checkWorkspaceAccess(workspace, session.user.id);
|
||||
if (!access.canDelete) {
|
||||
return apiErrors.forbidden('Only the workspace owner can delete it');
|
||||
}
|
||||
|
||||
// Delete Bunny provider videos first to avoid orphaned external assets.
|
||||
const [workspaceVersionRefs, workspaceAssetRefs] = await Promise.all([
|
||||
const [workspaceVersionRefs, workspaceAssetRefs, mediaUrls] = await Promise.all([
|
||||
db.videoVersion.findMany({
|
||||
where: {
|
||||
video: {
|
||||
@@ -194,21 +182,37 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
providerVideoId: true,
|
||||
},
|
||||
}),
|
||||
collectWorkspaceMediaUrls(workspaceId),
|
||||
]);
|
||||
await cleanupBunnyStreamVideos([
|
||||
|
||||
const bunnyRefs = [
|
||||
...workspaceVersionRefs,
|
||||
...workspaceAssetRefs.map((asset) => ({
|
||||
providerId: 'bunny',
|
||||
videoId: asset.providerVideoId as string,
|
||||
})),
|
||||
]);
|
||||
|
||||
// Clean up voice files from R2 before cascade delete removes comment rows
|
||||
await cleanupWorkspaceMediaFiles(workspaceId);
|
||||
];
|
||||
|
||||
await db.workspace.delete({ where: { id: workspaceId } });
|
||||
|
||||
const response = successResponse({ message: 'Workspace deleted' });
|
||||
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
||||
cleanupBunnyStreamVideosBestEffort(bunnyRefs),
|
||||
deleteMediaFilesBestEffort(mediaUrls),
|
||||
]);
|
||||
|
||||
const cleanupInput = {
|
||||
bunny: bunnyCleanupResult,
|
||||
r2: r2CleanupResult,
|
||||
};
|
||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||
if (cleanupWarnings) {
|
||||
logCleanupWarnings({ entityType: 'workspace', entityId: workspaceId }, cleanupInput);
|
||||
}
|
||||
|
||||
const response = successResponse({
|
||||
message: 'Workspace deleted',
|
||||
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
console.error('Error deleting workspace:', error);
|
||||
|
||||
@@ -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,6 +243,7 @@ export function VideoCard({ video, projectId, onDeleted }: VideoCardProps) {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{canManage ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -278,6 +280,7 @@ export function VideoCard({ video, projectId, onDeleted }: VideoCardProps) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -113,6 +113,8 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
||||
onOpenApprovalRequest,
|
||||
onOpenApprovalsPanel,
|
||||
}: VideoPageHeaderProps) {
|
||||
const canManageVideo = canShareVideo || canRequestApproval;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'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' && (
|
||||
<>
|
||||
{canManageVideo ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowVersionDialog(true)} className="hidden sm:inline-flex">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Version
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onOpenApprovalsPanel} className="hidden sm:inline-flex">
|
||||
<ListChecks className="h-4 w-4 mr-1" />
|
||||
@@ -208,6 +212,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canManageVideo ? (
|
||||
<div className="hidden">
|
||||
<VersionActionsDialog
|
||||
open={showVersionDialog}
|
||||
@@ -229,7 +234,9 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
||||
onCreateVersion={onCreateVersion}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(canShareVideo || canRequestApproval) && (
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -269,6 +276,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+56
-21
@@ -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,25 +26,50 @@ function getBunnyConfig(): { apiKey: string; libraryId: string } {
|
||||
return { apiKey, libraryId };
|
||||
}
|
||||
|
||||
export async function cleanupBunnyStreamVideos(videoRefs: BunnyVideoRef[]): Promise<void> {
|
||||
const normalizeVideoId = (value: string): string | null => {
|
||||
function normalizeVideoId(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return BUNNY_VIDEO_ID_PATTERN.test(trimmed) ? trimmed : null;
|
||||
};
|
||||
}
|
||||
|
||||
const bunnyVideoIds = [...new Set(
|
||||
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<BunnyCleanupResult> {
|
||||
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<string>();
|
||||
|
||||
await runWithConcurrency(bunnyVideoIds, BUNNY_DELETE_CONCURRENCY, async (bunnyVideoId) => {
|
||||
try {
|
||||
const response = await fetch(`${BUNNY_API_BASE}/library/${libraryId}/videos/${encodeURIComponent(bunnyVideoId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
@@ -50,20 +81,24 @@ export async function cleanupBunnyStreamVideos(videoRefs: BunnyVideoRef[]): Prom
|
||||
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),
|
||||
});
|
||||
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<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. */
|
||||
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<R2CleanupResult> {
|
||||
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) => {
|
||||
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<st
|
||||
*/
|
||||
export async function cleanupVideoMediaFiles(videoId: string) {
|
||||
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) {
|
||||
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) {
|
||||
const urls = await collectWorkspaceMediaUrls(workspaceId);
|
||||
await deleteMediaFiles(urls);
|
||||
await deleteMediaFilesBestEffort(urls);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user