jedmund-svelte/src/lib/server/cloudinary-audit.ts
Justin Edmund 1f04a96dad feat: add Cloudinary audit functionality
- Add comprehensive audit system to identify orphaned Cloudinary files
- Create audit script with dry-run and execute modes
- Add formatBytes utility for human-readable file sizes
- Implement comparison logic between Cloudinary and database references
- Add API endpoint for programmatic access to audit functionality
- Include documentation for Cloudinary management

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-16 16:56:05 +01:00

220 lines
5.5 KiB
TypeScript

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<CloudinaryResource[]> {
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<Set<string>> {
const publicIds = new Set<string>()
// 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 any[]
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 any[]
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<AuditResult> {
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
}