import { v2 as cloudinary } from 'cloudinary' import { prisma } from './database' import { extractPublicId } from './cloudinary' import { formatBytes } from '$lib/utils/format' export { formatBytes } export interface CloudinaryResource { public_id: string secure_url: string resource_type: string type: string format: string version: number width?: number height?: number bytes: number created_at: string folder?: string } export interface AuditResult { totalCloudinaryFiles: number totalDatabaseReferences: number orphanedFiles: CloudinaryResource[] orphanedTotalBytes: number missingFromCloudinary: string[] } /** * Fetches all resources from Cloudinary with pagination */ export async function fetchAllCloudinaryResources(): Promise { const resources: CloudinaryResource[] = [] let nextCursor: string | undefined do { try { const result = await cloudinary.api.resources({ type: 'upload', max_results: 500, next_cursor: nextCursor }) resources.push(...result.resources) nextCursor = result.next_cursor } catch (error) { console.error('Error fetching Cloudinary resources:', error) throw error } } while (nextCursor) return resources } /** * Gets all Cloudinary URLs/public IDs referenced in the database */ export async function fetchAllDatabaseCloudinaryReferences(): Promise> { const publicIds = new Set() // Get all Media table URLs const mediaRecords = await prisma.media.findMany({ select: { url: true, thumbnailUrl: true } }) for (const media of mediaRecords) { if (media.url?.includes('cloudinary.com')) { const publicId = extractPublicId(media.url) if (publicId) publicIds.add(publicId) } if (media.thumbnailUrl?.includes('cloudinary.com')) { const publicId = extractPublicId(media.thumbnailUrl) if (publicId) publicIds.add(publicId) } } // Get Project images const projects = await prisma.project.findMany({ select: { featuredImage: true, logoUrl: true, gallery: true } }) for (const project of projects) { if (project.featuredImage?.includes('cloudinary.com')) { const publicId = extractPublicId(project.featuredImage) if (publicId) publicIds.add(publicId) } if (project.logoUrl?.includes('cloudinary.com')) { const publicId = extractPublicId(project.logoUrl) if (publicId) publicIds.add(publicId) } if (project.gallery && typeof project.gallery === 'object') { const gallery = project.gallery as Record[] for (const item of gallery) { if (item.url?.includes('cloudinary.com')) { const publicId = extractPublicId(item.url) if (publicId) publicIds.add(publicId) } } } } // Get Post images const posts = await prisma.post.findMany({ select: { featuredImage: true, attachments: true } }) for (const post of posts) { if (post.featuredImage?.includes('cloudinary.com')) { const publicId = extractPublicId(post.featuredImage) if (publicId) publicIds.add(publicId) } if (post.attachments && typeof post.attachments === 'object') { const attachments = post.attachments as Record[] for (const attachment of attachments) { if (attachment.url?.includes('cloudinary.com')) { const publicId = extractPublicId(attachment.url) if (publicId) publicIds.add(publicId) } } } } return publicIds } /** * Performs a comprehensive audit of Cloudinary resources vs database references */ export async function auditCloudinaryResources(): Promise { console.log('Starting Cloudinary audit...') // Fetch all resources from Cloudinary const cloudinaryResources = await fetchAllCloudinaryResources() console.log(`Found ${cloudinaryResources.length} files in Cloudinary`) // Fetch all database references const databasePublicIds = await fetchAllDatabaseCloudinaryReferences() console.log(`Found ${databasePublicIds.size} Cloudinary references in database`) // Find orphaned files (in Cloudinary but not in database) const orphanedFiles: CloudinaryResource[] = [] let orphanedTotalBytes = 0 for (const resource of cloudinaryResources) { // Skip thumbnails generated by Cloudinary (they have specific naming patterns) if (resource.public_id.includes('_thumbnail_')) { continue } if (!databasePublicIds.has(resource.public_id)) { orphanedFiles.push(resource) orphanedTotalBytes += resource.bytes || 0 } } // Find missing files (in database but not in Cloudinary) const cloudinaryPublicIds = new Set(cloudinaryResources.map((r) => r.public_id)) const missingFromCloudinary: string[] = [] for (const publicId of databasePublicIds) { if (!cloudinaryPublicIds.has(publicId)) { missingFromCloudinary.push(publicId) } } return { totalCloudinaryFiles: cloudinaryResources.length, totalDatabaseReferences: databasePublicIds.size, orphanedFiles, orphanedTotalBytes, missingFromCloudinary } } /** * Deletes orphaned files from Cloudinary */ export async function deleteOrphanedFiles( publicIds: string[], dryRun = true ): Promise<{ attempted: number succeeded: number failed: string[] }> { const results = { attempted: publicIds.length, succeeded: 0, failed: [] as string[] } if (dryRun) { console.log(`DRY RUN: Would delete ${publicIds.length} files`) return { ...results, succeeded: publicIds.length } } for (const publicId of publicIds) { try { await cloudinary.uploader.destroy(publicId) results.succeeded++ console.log(`Deleted: ${publicId}`) } catch (error) { results.failed.push(publicId) console.error(`Failed to delete ${publicId}:`, error) } } return results } /** * Cleans up broken references from the database */ export async function cleanupBrokenReferences(publicIds: string[]): Promise<{ cleanedMedia: number cleanedProjects: number cleanedPosts: number errors: string[] }> { const results = { cleanedMedia: 0, cleanedProjects: 0, cleanedPosts: 0, errors: [] as string[] } try { // Clean up Media table const mediaToClean = await prisma.media.findMany({ where: { OR: [ { url: { contains: 'cloudinary.com' } }, { thumbnailUrl: { contains: 'cloudinary.com' } } ] } }) for (const media of mediaToClean) { let shouldDelete = false let updateThumbnail = false // Check if the main URL is broken if (media.url?.includes('cloudinary.com')) { const publicId = extractPublicId(media.url) if (publicId && publicIds.includes(publicId)) { // If the main URL is broken, we need to delete the entire record // since url is a required field shouldDelete = true } } // Check if only the thumbnail is broken if (!shouldDelete && media.thumbnailUrl?.includes('cloudinary.com')) { const publicId = extractPublicId(media.thumbnailUrl) if (publicId && publicIds.includes(publicId)) { updateThumbnail = true } } if (shouldDelete) { // Delete the media record entirely since the main URL is broken await prisma.media.delete({ where: { id: media.id } }) results.cleanedMedia++ } else if (updateThumbnail) { // Only update the thumbnail to null if it's broken await prisma.media.update({ where: { id: media.id }, data: { thumbnailUrl: null } }) results.cleanedMedia++ } } // Clean up Project table const projectsToClean = await prisma.project.findMany({ where: { OR: [ { featuredImage: { contains: 'cloudinary.com' } }, { logoUrl: { contains: 'cloudinary.com' } } ] } }) for (const project of projectsToClean) { let updated = false const updates: Record = {} if (project.featuredImage?.includes('cloudinary.com')) { const publicId = extractPublicId(project.featuredImage) if (publicId && publicIds.includes(publicId)) { updates.featuredImage = null updated = true } } if (project.logoUrl?.includes('cloudinary.com')) { const publicId = extractPublicId(project.logoUrl) if (publicId && publicIds.includes(publicId)) { updates.logoUrl = null updated = true } } // Handle gallery items if (project.gallery && typeof project.gallery === 'object') { const gallery = project.gallery as Record[] const cleanedGallery = gallery.filter((item) => { if (item.url?.includes('cloudinary.com')) { const publicId = extractPublicId(item.url) return !(publicId && publicIds.includes(publicId)) } return true }) if (cleanedGallery.length !== gallery.length) { updates.gallery = cleanedGallery updated = true } } if (updated) { await prisma.project.update({ where: { id: project.id }, data: updates }) results.cleanedProjects++ } } // Clean up Post table const postsToClean = await prisma.post.findMany({ where: { featuredImage: { contains: 'cloudinary.com' } } }) for (const post of postsToClean) { let updated = false const updates: Record = {} if (post.featuredImage?.includes('cloudinary.com')) { const publicId = extractPublicId(post.featuredImage) if (publicId && publicIds.includes(publicId)) { updates.featuredImage = null updated = true } } // Handle attachments if (post.attachments && typeof post.attachments === 'object') { const attachments = post.attachments as Record[] const cleanedAttachments = attachments.filter((attachment) => { if (attachment.url?.includes('cloudinary.com')) { const publicId = extractPublicId(attachment.url) return !(publicId && publicIds.includes(publicId)) } return true }) if (cleanedAttachments.length !== attachments.length) { updates.attachments = cleanedAttachments updated = true } } if (updated) { await prisma.post.update({ where: { id: post.id }, data: updates }) results.cleanedPosts++ } } } catch (error) { results.errors.push(error instanceof Error ? error.message : 'Unknown error') console.error('Error cleaning up broken references:', error) } return results }