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 ? (
<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>
) : (
+25 -9
View File
@@ -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);
+48 -44
View File
@@ -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);
+4 -1
View File
@@ -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
View File
@@ -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}`);
}
+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. */
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);
}