From bca8e86e1008d8bb64ce64701c2492f1161d35ac Mon Sep 17 00:00:00 2001 From: Syed Muzamil Date: Mon, 8 Jun 2026 15:43:56 +0530 Subject: [PATCH 1/5] feat: client upload --- CHANGELOG.md | 20 ++ README.md | 24 ++ bun.lockb | Bin 115980 -> 137183 bytes package.json | 18 +- src/client/CloudinaryClientUploadHandler.ts | 114 +++++++++ src/getClientUploadRoute.ts | 86 +++++++ src/handleUpload.ts | 244 +++++++------------- src/index.ts | 135 ++++++++++- src/publicID.ts | 78 +++++++ src/types.ts | 11 + 10 files changed, 564 insertions(+), 166 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/client/CloudinaryClientUploadHandler.ts create mode 100644 src/getClientUploadRoute.ts create mode 100644 src/publicID.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5662912 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- **Client-side Uploads**: Added support for uploading files directly from the browser to Cloudinary. This bypasses server limits (e.g., Vercel's 4.5MB request limit) and speeds up the upload process. + - Enable via `clientUploads: true` in the plugin options. + - Automatically handles generating a secure signature on the server, avoiding exposure of your Cloudinary API Secret to the frontend. + - Fully supports Payload CMS `useCompositePrefixes` for flexible document paths. +- Proper routing and processing to extract full Cloudinary metadata (including PDF thumbnail generation and Image dimension checks) directly from the client upload payload so no functionality is lost when doing client-side uploads. + +### Changed +- Exported `./client` module in `package.json` so the `CloudinaryClientUploadHandler` is correctly resolved by the Payload Admin UI. +- Updated the bundle script to properly mark external dependencies, fixing bundling issues with `@payloadcms/plugin-cloud-storage` internals. +- Internal refactor of `handleUpload` to gracefully intercept `clientUploadContext` payloads. diff --git a/README.md b/README.md index b0f9847..f49d6cb 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,28 @@ cloudinaryStorage({ }) ``` +### Client-side Uploads + +Bypass server limits (like those on Vercel) by uploading files directly from the browser to Cloudinary. + +To enable client-side uploads, you must expose your Cloudinary API Key to the frontend using an environment variable named exactly `NEXT_PUBLIC_CLOUDINARY_API_KEY`. + +**Note:** Only the API Key is exposed to the frontend. Your Cloudinary API Secret remains safely on the server to generate signed upload credentials. + +```typescript +// 1. In your .env file +NEXT_PUBLIC_CLOUDINARY_API_KEY=your_api_key_here +CLOUDINARY_API_SECRET=your_api_secret_here +CLOUDINARY_CLOUD_NAME=your_cloud_name_here + +// 2. In your payload.config.ts +cloudinaryStorage({ + // ... other options + clientUploads: true, // Enable direct client-side uploads + useCompositePrefixes: true, // (Optional) Use composite prefixes (collection + document) +}) +``` + ### PDF Support The plugin provides special handling for PDF files, including: @@ -502,6 +524,8 @@ const CloudinaryImage = ({ media }) => { | `folder` | `string` | `'payload-media'` | Base folder path in Cloudinary | | `disableLocalStorage` | `boolean` | `true` | Whether to disable local storage | | `enabled` | `boolean` | `true` | Whether to enable the plugin | +| `clientUploads` | `boolean` | `false` | Bypass server limits by uploading files directly from the browser | +| `useCompositePrefixes` | `boolean` | `false` | Use composite prefixes (collection + document) for client uploads | | `customFields` | `Field[]` | `[]` | Custom fields to add to the media collection | | `supportDynamicFolderMode` | `boolean` | `true` | Whether to support Dynamic Folder Mode for newer Cloudinary accounts | | `publicID` | `Object` | (see below) | Public ID configuration options | diff --git a/bun.lockb b/bun.lockb index 570ef517ac70b1157738e2e4beb7853b9a502ed8..2c7fae70529915958040ae7e76ab294c53b227b9 100755 GIT binary patch delta 38065 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
    UtK%4mOg|fP2@sa3Q3eME!Oid8rvrmVoSywC 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 t#Eyi9*aPKNLyXzO{D>}rsTMp=18.0.0" @@ -55,6 +57,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./client": { + "types": "./dist/client/CloudinaryClientUploadHandler.d.ts", + "import": "./dist/client/CloudinaryClientUploadHandler.js" } } } diff --git a/src/client/CloudinaryClientUploadHandler.ts b/src/client/CloudinaryClientUploadHandler.ts new file mode 100644 index 0000000..bd9a643 --- /dev/null +++ b/src/client/CloudinaryClientUploadHandler.ts @@ -0,0 +1,114 @@ +'use client' + +import { createClientUploadHandler } from '@payloadcms/plugin-cloud-storage/client' + +export type CloudinaryClientUploadHandlerExtra = { + useCompositePrefixes: boolean +} + +export const CloudinaryClientUploadHandler = + createClientUploadHandler({ + handler: async ({ + apiRoute, + collectionSlug, + docPrefix, + extra: { useCompositePrefixes = false }, + file, + prefix, + serverHandlerPath, + serverURL, + updateFilename, + }) => { + // Remove trailing slash from serverURL and apiRoute + const safeServerURL = serverURL.replace(/\/$/, '') + const safeApiRoute = apiRoute.replace(/^\/|\/$/g, '') + const safeHandlerPath = serverHandlerPath.replace(/^\//, '') + + const endpointRoute = `${safeServerURL}/${safeApiRoute}/${safeHandlerPath}` + + // Step 1: Get signed upload parameters from our server + const signatureResponse = await fetch(endpointRoute, { + method: 'POST', + credentials: 'include', + body: JSON.stringify({ + collectionSlug, + docPrefix, + filename: file.name, + filesize: file.size, + mimeType: file.type, + }), + }) + + if (!signatureResponse.ok) { + const { errors } = (await signatureResponse.json()) as { + errors: { message: string }[] + } + throw new Error(errors.reduce((acc, err) => `${acc ? `${acc}, ` : ''}${err.message}`, '')) + } + + const { + signature, + timestamp, + apiKey, + cloudName, + publicId, + resourceType, + uploadUrl, + uploadParams, + } = (await signatureResponse.json()) as { + signature: string + timestamp: number + apiKey: string + cloudName: string + publicId: string + resourceType: string + uploadUrl: string + uploadParams: Record + } + + // Step 2: Upload directly to Cloudinary using the signed params + const formData = new FormData() + formData.append('file', file) + + // Use apiKey from server response — no need for NEXT_PUBLIC_CLOUDINARY_API_KEY env var + formData.append('api_key', apiKey) + formData.append('signature', signature) + + // Append all signed params to the upload (Cloudinary validates them against the signature) + for (const [key, value] of Object.entries(uploadParams)) { + if (value !== undefined && value !== null) { + formData.append(key, String(value)) + } + } + + const uploadResponse = await fetch(uploadUrl, { + method: 'POST', + body: formData, + }) + + if (!uploadResponse.ok) { + const errorBody = await uploadResponse.text() + throw new Error(`Cloudinary upload failed: ${errorBody}`) + } + + const uploadResult = (await uploadResponse.json()) as any + + // Step 3: Compute sanitized filename if it changed + // The public_id from server may have sanitized the original filename + const resultFilename = uploadResult.original_filename + ? `${uploadResult.original_filename}.${uploadResult.format}` + : file.name + + if (resultFilename !== file.name) { + updateFilename(resultFilename) + } + + // Step 4: Return the upload result as clientUploadContext + // The afterChange hook on the server will extract this and persist Cloudinary metadata + return { + prefix: docPrefix || '', + publicId: uploadResult.public_id, + uploadResult, + } + }, + }) diff --git a/src/getClientUploadRoute.ts b/src/getClientUploadRoute.ts new file mode 100644 index 0000000..3343159 --- /dev/null +++ b/src/getClientUploadRoute.ts @@ -0,0 +1,86 @@ +import { v2 as cloudinary } from 'cloudinary' +import type { PayloadRequest } from 'payload' +import type { PublicIDOptions, CloudinaryVersioningOptions } from './types' +import path from 'path' +import { getResourceType } from './utils' +import { generatePublicID } from './publicID' + +export interface GetClientUploadRouteOptions { + cloudName: string + apiKey: string + apiSecret: string + folder: string + publicID?: PublicIDOptions + versioning?: CloudinaryVersioningOptions +} + +export const getClientUploadRoute = (options: GetClientUploadRouteOptions) => { + return async (req: PayloadRequest) => { + // Reconfigure cloudinary here to ensure it uses the correct config for this plugin instance + cloudinary.config({ + cloud_name: options.cloudName, + api_key: options.apiKey, + api_secret: options.apiSecret, + }) + + const body = (typeof req.json === 'function' ? await req.json() : req.body) as Record + const { collectionSlug, docPrefix, filename, filesize, mimeType } = body || {} + + try { + const timestamp = Math.floor(Date.now() / 1000) + + // Build the folder path the same way handleUpload does + const folderPath = docPrefix + ? path.posix.join(options.folder, docPrefix) + : options.folder + + // Generate public_id using the same shared logic as handleUpload + const publicIdValue = generatePublicID(filename, folderPath, options.publicID) + + // Determine resource type from filename extension + const ext = path.extname(filename).toLowerCase() + const resourceType = getResourceType(ext) + + // Build the parameters that MUST be signed. + // Cloudinary requires all params (except file, api_key, resource_type, cloud_name) + // to be included in the signature. + const paramsToSign: Record = { + timestamp, + public_id: publicIdValue, + asset_folder: folderPath, + use_filename: true, + unique_filename: true, + } + + // Add versioning invalidation if enabled + if (options.versioning?.autoInvalidate) { + paramsToSign.invalidate = true + } + + const signature = cloudinary.utils.api_sign_request( + paramsToSign, + options.apiSecret + ) + + // Build the correct upload URL with the specific resource type + const uploadUrl = `https://api.cloudinary.com/v1_1/${options.cloudName}/${resourceType}/upload` + + return Response.json({ + signature, + timestamp, + apiKey: options.apiKey, + cloudName: options.cloudName, + publicId: publicIdValue, + resourceType, + uploadUrl, + // Send all signed params so the client can include them in the upload + uploadParams: paramsToSign, + }) + } catch (error) { + return Response.json( + { errors: [{ message: 'Failed to generate upload signature' }] }, + { status: 400 } + ) + } + } +} diff --git a/src/handleUpload.ts b/src/handleUpload.ts index c3d4f6e..0ba7f35 100644 --- a/src/handleUpload.ts +++ b/src/handleUpload.ts @@ -81,80 +81,9 @@ const getUploadOptions = ( } }; -/** - * Sanitize a string to be used as part of a public ID - * @param str String to sanitize - * @returns Sanitized string - */ -const sanitizeForPublicID = (str: string): string => { - return str - .toLowerCase() - .replace(/[^a-z0-9]/g, "-") // Replace any character that's not a letter or number with a hyphen - .replace(/-+/g, "-") // Replace consecutive hyphens with a single hyphen - .replace(/^-|-$/g, ""); // Remove leading or trailing hyphens -}; - -/** - * Generate a public ID based on the publicID options - * @param filename Original filename - * @param folderPath Folder path - * @param publicIDOptions Public ID options - * @returns Generated public ID - */ -const generatePublicID = ( - filename: string, - folderPath: string, - publicIDOptions?: PublicIDOptions, -): string => { - // If a custom generator function is provided, use it - if (publicIDOptions?.generatePublicID) { - return publicIDOptions.generatePublicID( - filename, - path.dirname(folderPath), - path.basename(folderPath), - ); - } +import { sanitizeForPublicID, generatePublicID } from "./publicID"; - // Get file extension and resource type - const ext = path.extname(filename).toLowerCase(); - const resourceType = getResourceType(ext); - const isRawFile = resourceType === "raw"; - // If publicID is disabled, just return the path with sanitization - if (publicIDOptions?.enabled === false) { - const filenameWithoutExt = path.basename(filename, path.extname(filename)); - const sanitizedFilename = sanitizeForPublicID(filenameWithoutExt); - // For raw files, preserve the extension - const finalFilename = isRawFile - ? `${sanitizedFilename}${ext}` - : sanitizedFilename; - return path.posix.join(folderPath, finalFilename); - } - - // Default behavior - use filename (if enabled) and make it unique (if enabled) - const useFilename = publicIDOptions?.useFilename !== false; - const uniqueFilename = publicIDOptions?.uniqueFilename !== false; - - const timestamp = uniqueFilename ? `_${Date.now()}` : ""; - - if (useFilename) { - // Use the filename as part of the public ID (sanitized) - const filenameWithoutExt = path.basename(filename, path.extname(filename)); - const sanitizedFilename = sanitizeForPublicID(filenameWithoutExt); - // For raw files, preserve the extension - const finalFilename = isRawFile - ? `${sanitizedFilename}${timestamp}${ext}` - : `${sanitizedFilename}${timestamp}`; - return path.posix.join(folderPath, finalFilename); - } - - // Generate a timestamp-based ID if not using filename - // For raw files, we need to preserve the extension even with generated IDs - const finalFilename = isRawFile - ? `media${timestamp}${ext}` - : `media${timestamp}`; - return path.posix.join(folderPath, finalFilename); -}; /** * Check if a file is a PDF based on its file extension @@ -198,7 +127,7 @@ export const getHandleUpload = versioning, publicID, }: Args): HandleUpload => - async ({ data, file }) => { + async ({ data, file, clientUploadContext }) => { // Construct the folder path with proper handling of prefix const folderPath = data.prefix ? path.posix.join(folder, data.prefix) @@ -211,12 +140,87 @@ export const getHandleUpload = const uploadOptions: UploadApiOptions = { ...getUploadOptions(file.filename, versioning), public_id: publicIdValue, - // folder: path.dirname(publicIdValue), // Extract folder from public_id use_filename: publicID?.useFilename !== false, unique_filename: publicID?.uniqueFilename !== false, asset_folder: folderPath, }; + const processUploadResult = async (result: any) => { + const isPDFFile = isPDF(file.filename); + const baseMetadata = { + public_id: result.public_id, + resource_type: result.resource_type, + format: isPDFFile ? result.format || "pdf" : result.format, + secure_url: result.secure_url, + bytes: result.bytes, + created_at: result.created_at, + version: result.version ? String(result.version) : result.version, + version_id: result.version_id, + }; + + let typeSpecificMetadata = {}; + + if (result.resource_type === "video") { + typeSpecificMetadata = { + duration: result.duration, + width: result.width, + height: result.height, + eager: result.eager, + }; + } else if (isPDFFile) { + let pageCount = 1; + if (result.pages) { + pageCount = result.pages; + } else { + pageCount = await getPDFPageCount(cloudinary, result.public_id); + } + + const publicId = result.public_id.endsWith(".pdf") + ? result.public_id + : `${result.public_id}.pdf`; + + typeSpecificMetadata = { + pages: pageCount, + selected_page: 1, + width: result.width, + height: result.height, + format: result.format || "pdf", + thumbnail_url: `https://res.cloudinary.com/${cloudinary.config().cloud_name}/image/upload/pg_1,f_jpg,q_auto/${publicId}`, + }; + } else if (result.resource_type === "image") { + typeSpecificMetadata = { + width: result.width, + height: result.height, + }; + } + + const finalMetadata = { + ...baseMetadata, + ...typeSpecificMetadata, + }; + if (isPDFFile) { + finalMetadata.format = "pdf"; + } + data.cloudinary = finalMetadata; + + if (versioning?.enabled && versioning?.storeHistory) { + data.versions = data.versions || []; + data.versions.push({ + version: result.version ? String(result.version) : "", + version_id: result.version_id || "", + created_at: result.created_at || new Date().toISOString(), + secure_url: result.secure_url || "", + }); + } + return data; + }; + + // If file was uploaded on the client, skip upload and process result + if (clientUploadContext && typeof clientUploadContext === 'object' && 'uploadResult' in clientUploadContext) { + const { uploadResult } = clientUploadContext as { uploadResult: any }; + return await processUploadResult(uploadResult); + } + return new Promise((resolve, reject) => { try { const uploadStream = cloudinary.uploader.upload_stream( @@ -229,93 +233,15 @@ export const getHandleUpload = } if (result) { - const isPDFFile = isPDF(file.filename); - const baseMetadata = { - public_id: result.public_id, - resource_type: result.resource_type, - // For PDFs, explicitly set format to "pdf" if not provided - format: isPDFFile ? result.format || "pdf" : result.format, - secure_url: result.secure_url, - bytes: result.bytes, - created_at: result.created_at, - // Ensure version is always stored as string to match field type - version: result.version - ? String(result.version) - : result.version, - version_id: result.version_id, - }; - - // Add metadata based on resource type - let typeSpecificMetadata = {}; - - if (result.resource_type === "video") { - typeSpecificMetadata = { - duration: result.duration, - width: result.width, - height: result.height, - eager: result.eager, - }; - } else if (isPDFFile) { - // Handle PDF specific metadata - let pageCount = 1; - - // Try to get page count from result, otherwise call the API - if (result.pages) { - pageCount = result.pages; - } else { - // Use the separate async function to get page count - pageCount = await getPDFPageCount( - cloudinary, - result.public_id, - ); - } - - // Ensure public_id has .pdf extension (but don't duplicate it) - const publicId = result.public_id.endsWith(".pdf") - ? result.public_id - : `${result.public_id}.pdf`; - - typeSpecificMetadata = { - pages: pageCount, - selected_page: 1, // Default to first page for thumbnails - width: result.width, - height: result.height, - format: result.format || "pdf", // Explicitly set format to pdf - // Generate a thumbnail URL for the PDF using Cloudinary's PDF thumbnail feature - thumbnail_url: `https://res.cloudinary.com/${cloudinary.config().cloud_name}/image/upload/pg_1,f_jpg,q_auto/${publicId}`, - }; - } else if (result.resource_type === "image") { - typeSpecificMetadata = { - width: result.width, - height: result.height, - }; - } - - // Combine base and type-specific metadata - // For PDFs, ensure format is explicitly set to "pdf" - const finalMetadata = { - ...baseMetadata, - ...typeSpecificMetadata, - }; - if (isPDFFile) { - finalMetadata.format = "pdf"; - } - data.cloudinary = finalMetadata; - - // If versioning and history storage is enabled, store version info - if (versioning?.enabled && versioning?.storeHistory) { - data.versions = data.versions || []; - data.versions.push({ - // Store version as a string to match the field type expectation - version: result.version ? String(result.version) : "", - version_id: result.version_id || "", - created_at: result.created_at || new Date().toISOString(), - secure_url: result.secure_url || "", - }); + try { + const processedData = await processUploadResult(result); + resolve(processedData); + } catch (err) { + reject(err); } + } else { + resolve(data); } - - resolve(data); }, ); diff --git a/src/index.ts b/src/index.ts index cce0136..9f7aa4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,11 @@ import type { Config } from "payload"; import { v2 as cloudinary } from "cloudinary"; import { cloudStoragePlugin } from "@payloadcms/plugin-cloud-storage"; +import { initClientUploads } from '@payloadcms/plugin-cloud-storage/utilities'; + +import type { CloudinaryClientUploadHandlerExtra } from './client/CloudinaryClientUploadHandler'; +import { getClientUploadRoute } from './getClientUploadRoute'; + import path from "path"; import { getGenerateURL } from "./generateURL"; @@ -59,9 +64,12 @@ export const cloudinaryStorage: CloudinaryStoragePlugin = const adapter = cloudinaryStorageInternal(cloudinaryOptions); + // Ensure collections is always an object (defensive handling) + const pluginCollections = cloudinaryOptions.collections || {}; + // Add adapter to each collection option object const collectionsWithAdapter: CloudStoragePluginOptions["collections"] = - Object.entries(cloudinaryOptions.collections).reduce( + Object.entries(pluginCollections).reduce( (acc, [slug, collOptions]) => ({ ...acc, [slug]: { @@ -161,10 +169,135 @@ export const cloudinaryStorage: CloudinaryStoragePlugin = ...versionFieldsToAdd, ]; + // Add afterChange hook for client upload metadata persistence. + // When clientUploads is enabled, plugin-cloud-storage SKIPS calling handleUpload + // for client-uploaded files (since the file is already on Cloudinary). + // This hook catches those cases and persists the Cloudinary metadata that + // would normally be saved by handleUpload. + if (cloudinaryOptions.clientUploads) { + const existingAfterChange = modifiedCollection.hooks?.afterChange || []; + modifiedCollection.hooks = { + ...modifiedCollection.hooks, + afterChange: [ + ...existingAfterChange, + async ({ doc, req, operation }) => { + // Skip if this is an internal update to prevent infinite loop + if (req.context?.skipCloudinaryClientUpload) { + return doc; + } + + // Check if this came from a client upload by looking at the file's clientUploadContext + const clientUploadContext = req.file?.clientUploadContext as { uploadResult?: any } | undefined; + if (!clientUploadContext?.uploadResult) { + return doc; + } + + // Already has cloudinary metadata (e.g., from a prior hook run) + if (doc.cloudinary?.public_id) { + return doc; + } + + const result = clientUploadContext.uploadResult; + const isPDFFile = doc.filename && path.extname(doc.filename).toLowerCase() === '.pdf'; + + const baseMetadata = { + public_id: result.public_id, + resource_type: result.resource_type, + format: isPDFFile ? result.format || 'pdf' : result.format, + secure_url: result.secure_url, + bytes: result.bytes, + created_at: result.created_at, + version: result.version ? String(result.version) : result.version, + version_id: result.version_id, + }; + + let typeSpecificMetadata: Record = {}; + + if (result.resource_type === 'video') { + typeSpecificMetadata = { + duration: result.duration, + width: result.width, + height: result.height, + eager: result.eager, + }; + } else if (isPDFFile) { + typeSpecificMetadata = { + pages: result.pages || 1, + selected_page: 1, + width: result.width, + height: result.height, + format: 'pdf', + thumbnail_url: `https://res.cloudinary.com/${cloudinaryOptions.config.cloud_name}/image/upload/pg_1,f_jpg,q_auto/${result.public_id}.pdf`, + }; + } else if (result.resource_type === 'image') { + typeSpecificMetadata = { + width: result.width, + height: result.height, + }; + } + + const cloudinaryMetadata = { + ...baseMetadata, + ...typeSpecificMetadata, + }; + + // Persist the metadata via an update call + if (!req.context) { + req.context = {}; + } + req.context.skipCloudinaryClientUpload = true; + + try { + await req.payload.update({ + id: doc.id, + collection: collection.slug, + data: { cloudinary: cloudinaryMetadata }, + depth: 0, + req, + }); + } finally { + delete req.context.skipCloudinaryClientUpload; + } + + return { + ...doc, + cloudinary: cloudinaryMetadata, + }; + }, + ], + }; + } + return modifiedCollection; }), }; + // Initialize client uploads only if explicitly enabled and collections are configured + if (cloudinaryOptions.clientUploads && Object.keys(pluginCollections).length > 0) { + initClientUploads< + CloudinaryClientUploadHandlerExtra, + CloudinaryStorageOptions["collections"][string] + >({ + clientHandler: + "payload-cloudinary/client#CloudinaryClientUploadHandler", + collections: pluginCollections, + config: incomingConfig, + enabled: Boolean(cloudinaryOptions.clientUploads), + extraClientHandlerProps: () => ({ + useCompositePrefixes: !!cloudinaryOptions.useCompositePrefixes, + }), + serverHandler: getClientUploadRoute({ + apiKey: cloudinaryOptions.config.api_key, + apiSecret: cloudinaryOptions.config.api_secret, + cloudName: cloudinaryOptions.config.cloud_name, + folder: cloudinaryOptions.folder || 'payload-media', + publicID: cloudinaryOptions.publicID, + versioning: cloudinaryOptions.versioning, + }), + serverHandlerPath: "/cloudinary-client-upload-route", + }); + } + return cloudStoragePlugin({ collections: collectionsWithAdapter, })(config); diff --git a/src/publicID.ts b/src/publicID.ts new file mode 100644 index 0000000..e77253a --- /dev/null +++ b/src/publicID.ts @@ -0,0 +1,78 @@ +import path from "path"; +import { getResourceType } from "./utils"; +import type { PublicIDOptions } from "./types"; + +/** + * Sanitize a string to be used as part of a public ID + * @param str String to sanitize + * @returns Sanitized string + */ +export const sanitizeForPublicID = (str: string): string => { + return str + .toLowerCase() + .replace(/[^a-z0-9]/g, "-") // Replace any character that's not a letter or number with a hyphen + .replace(/-+/g, "-") // Replace consecutive hyphens with a single hyphen + .replace(/^-|-$/g, ""); // Remove leading or trailing hyphens +}; + +/** + * Generate a public ID based on the publicID options + * @param filename Original filename + * @param folderPath Folder path + * @param publicIDOptions Public ID options + * @returns Generated public ID + */ +export const generatePublicID = ( + filename: string, + folderPath: string, + publicIDOptions?: PublicIDOptions, +): string => { + // If a custom generator function is provided, use it + if (publicIDOptions?.generatePublicID) { + return publicIDOptions.generatePublicID( + filename, + path.dirname(folderPath), + path.basename(folderPath), + ); + } + + // Get file extension and resource type + const ext = path.extname(filename).toLowerCase(); + const resourceType = getResourceType(ext); + const isRawFile = resourceType === "raw"; + + // If publicID is disabled, just return the path with sanitization + if (publicIDOptions?.enabled === false) { + const filenameWithoutExt = path.basename(filename, path.extname(filename)); + const sanitizedFilename = sanitizeForPublicID(filenameWithoutExt); + // For raw files, preserve the extension + const finalFilename = isRawFile + ? `${sanitizedFilename}${ext}` + : sanitizedFilename; + return path.posix.join(folderPath, finalFilename); + } + + // Default behavior - use filename (if enabled) and make it unique (if enabled) + const useFilename = publicIDOptions?.useFilename !== false; + const uniqueFilename = publicIDOptions?.uniqueFilename !== false; + + const timestamp = uniqueFilename ? `_${Date.now()}` : ""; + + if (useFilename) { + // Use the filename as part of the public ID (sanitized) + const filenameWithoutExt = path.basename(filename, path.extname(filename)); + const sanitizedFilename = sanitizeForPublicID(filenameWithoutExt); + // For raw files, preserve the extension + const finalFilename = isRawFile + ? `${sanitizedFilename}${timestamp}${ext}` + : `${sanitizedFilename}${timestamp}`; + return path.posix.join(folderPath, finalFilename); + } + + // Generate a timestamp-based ID if not using filename + // For raw files, we need to preserve the extension even with generated IDs + const finalFilename = isRawFile + ? `media${timestamp}${ext}` + : `media${timestamp}`; + return path.posix.join(folderPath, finalFilename); +}; diff --git a/src/types.ts b/src/types.ts index b694505..0d8a5d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,7 @@ import type { Adapter, CollectionOptions, GenerateURL, + ClientUploadsConfig, } from "@payloadcms/plugin-cloud-storage/types"; import type { Plugin, UploadCollectionSlug, Field } from "payload"; @@ -135,6 +136,16 @@ export type CloudinaryStorageOptions = { */ enabled?: boolean; + /** + * Do uploads directly on the client, to bypass limits on Vercel. + */ + clientUploads?: ClientUploadsConfig; + + /** + * Use composite prefixes (collection + document) + */ + useCompositePrefixes?: boolean; + /** * Versioning configuration options */ From 414c3e9154d0c4d108a2fcd362a9e4715ac306a1 Mon Sep 17 00:00:00 2001 From: Syed Muzamil Date: Tue, 9 Jun 2026 14:30:24 +0530 Subject: [PATCH 2/5] feat: fix client-side uploads, update docs, fix publish script - Extract shared publicID module for consistent ID generation - Rewrite server handler with proper signature generation - Rewrite client handler to use server-provided params (no NEXT_PUBLIC env var) - Add afterChange hook for client upload metadata persistence - Guard initClientUploads with clientUploads check - Fix example config missing collections - Update CHANGELOG.md and README.md with client upload docs - Fix publish script with non-interactive mode and npm token support --- CHANGELOG.md | 48 ++++++++++++++++++++++++++++++++++++++------- README.md | 35 ++++++++++++++++++++++----------- package.json | 2 +- scripts/publish.sh | 40 +++++++++++++++++++++++++++++++++++-- src/handleUpload.ts | 4 +--- 5 files changed, 105 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5662912..358d367 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,50 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.4.0-beta.1] - 2025-06-09 ### Added -- **Client-side Uploads**: Added support for uploading files directly from the browser to Cloudinary. This bypasses server limits (e.g., Vercel's 4.5MB request limit) and speeds up the upload process. +- **Client-side Uploads**: Upload files directly from the browser to Cloudinary, bypassing server limits (e.g., Vercel's 4.5MB request limit). - Enable via `clientUploads: true` in the plugin options. - - Automatically handles generating a secure signature on the server, avoiding exposure of your Cloudinary API Secret to the frontend. - - Fully supports Payload CMS `useCompositePrefixes` for flexible document paths. -- Proper routing and processing to extract full Cloudinary metadata (including PDF thumbnail generation and Image dimension checks) directly from the client upload payload so no functionality is lost when doing client-side uploads. + - Secure signature generation on the server — your Cloudinary API Secret is never exposed to the frontend. + - `NEXT_PUBLIC_CLOUDINARY_API_KEY` environment variable is **no longer required** — the API key is securely passed from the server handler. + - Full support for `useCompositePrefixes` for flexible document paths. +- **Metadata persistence for client uploads**: Added an `afterChange` hook that persists Cloudinary metadata (public_id, secure_url, format, dimensions, etc.) for client-uploaded files. This fixes the core issue where `plugin-cloud-storage` skips `handleUpload` for client uploads. +- **Shared public ID generation**: Extracted `generatePublicID` and `sanitizeForPublicID` into a shared `publicID.ts` module so both server-side and client-side uploads produce identical public IDs. ### Changed +- **Server handler rewrite** (`getClientUploadRoute.ts`): Now generates proper `public_id` using the same logic as `handleUpload`, signs all upload parameters (not just timestamp), and returns the API key and resource-type-specific upload URL. +- **Client handler rewrite** (`CloudinaryClientUploadHandler.ts`): Uses server-provided signed params and API key instead of manually constructing FormData with environment variables. +- **Guarded `initClientUploads`**: Only initializes client upload providers when `clientUploads` is truthy and collections are configured, with the correct `enabled` flag. +- **Defensive `collections` handling**: Plugin no longer crashes if `collections` is omitted from config — defaults to an empty object. - Exported `./client` module in `package.json` so the `CloudinaryClientUploadHandler` is correctly resolved by the Payload Admin UI. -- Updated the bundle script to properly mark external dependencies, fixing bundling issues with `@payloadcms/plugin-cloud-storage` internals. -- Internal refactor of `handleUpload` to gracefully intercept `clientUploadContext` payloads. +- Internal refactor: `handleUpload` now imports from shared `publicID.ts` module. + +### Fixed +- Fixed signature mismatch errors on client uploads — server now signs all parameters the client sends to Cloudinary. +- Fixed `public_id` inconsistency between client and server upload paths. +- Fixed example config missing required `collections` property. +- Removed orphaned dist files (`createServerHandler`, `generateClientUploadSignature`) from a previous approach. + +### Removed +- **`NEXT_PUBLIC_CLOUDINARY_API_KEY` dependency**: The client handler no longer reads from `process.env`. API key is provided by the server handler response. + +## [2.3.0] - 2025-05-20 + +### Added +- **PDF Support**: Added comprehensive PDF handling with thumbnail generation + - Automatic page count detection + - Page selection for thumbnails + - Thumbnail URL generation + - Admin UI PDF thumbnails +- **Dynamic Folder Mode**: Support for Cloudinary's Dynamic Folder Mode via `asset_folder` parameter +- **Custom Fields**: Support for adding custom fields to media collections via `customFields` option +- **Public ID Customization**: Full control over Cloudinary public ID generation + - `useFilename` option + - `uniqueFilename` option + - Custom `generatePublicID` function support +- **Versioning**: Added version history tracking with `storeHistory` option + +### Changed +- Improved file type detection with comprehensive extension lists +- Better handling of raw file uploads (documents, archives, etc.) diff --git a/README.md b/README.md index f49d6cb..1569895 100644 --- a/README.md +++ b/README.md @@ -181,24 +181,37 @@ cloudinaryStorage({ Bypass server limits (like those on Vercel) by uploading files directly from the browser to Cloudinary. -To enable client-side uploads, you must expose your Cloudinary API Key to the frontend using an environment variable named exactly `NEXT_PUBLIC_CLOUDINARY_API_KEY`. - -**Note:** Only the API Key is exposed to the frontend. Your Cloudinary API Secret remains safely on the server to generate signed upload credentials. - ```typescript -// 1. In your .env file -NEXT_PUBLIC_CLOUDINARY_API_KEY=your_api_key_here -CLOUDINARY_API_SECRET=your_api_secret_here -CLOUDINARY_CLOUD_NAME=your_cloud_name_here - -// 2. In your payload.config.ts +// In your payload.config.ts cloudinaryStorage({ - // ... other options + config: { + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, + }, + collections: { + media: true, + }, clientUploads: true, // Enable direct client-side uploads useCompositePrefixes: true, // (Optional) Use composite prefixes (collection + document) }) ``` +> **Note:** No additional environment variables are needed on the frontend. The plugin automatically passes the API key from the server to the client via a secure signature endpoint. Your API Secret is never exposed to the browser. + +#### How It Works + +1. When a user uploads a file in the admin panel, the browser requests a **signed upload credential** from your server (`/api/cloudinary-client-upload-route`). +2. The server generates a `public_id`, signs all upload parameters with your API Secret, and returns the signature + params. +3. The browser uploads the file **directly to Cloudinary** using the signed params — the file never passes through your server. +4. After the upload, an `afterChange` hook on the server persists all Cloudinary metadata (public_id, secure_url, dimensions, etc.) to the document. + +This approach ensures: +- No server bandwidth or memory usage for file uploads +- No request size limits (Vercel's 4.5MB limit is bypassed) +- Full Cloudinary metadata (including PDF pages, video duration, etc.) is still saved +- `publicID` and `folder` configuration is respected identically to server-side uploads + ### PDF Support The plugin provides special handling for PDF files, including: diff --git a/package.json b/package.json index 702bd02..ea2f37b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload-cloudinary", - "version": "2.3.0", + "version": "2.4.0-beta.1", "description": "A Cloudinary storage plugin for Payload CMS", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/scripts/publish.sh b/scripts/publish.sh index ccbcfc9..7e48286 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -18,13 +18,24 @@ publish_version() { print_message "📦 Publishing version ${version} with tag: ${tag}..." "$YELLOW" - # Update version in package.json - npm version $version --no-git-tag-version + # Update version in package.json (skip if already at the target version) + local current=$(node -p "require('./package.json').version") + if [ "$current" != "$version" ]; then + npm version $version --no-git-tag-version + else + print_message "Version already set to ${version}, skipping version bump" "$YELLOW" + fi # Build the project print_message "🏗️ Building project..." "$YELLOW" bun run build || exit 1 + # Configure npm auth if NPM_TOKEN is set + if [ -n "$NPM_TOKEN" ]; then + print_message "🔑 Configuring npm authentication..." "$YELLOW" + npm config set //registry.npmjs.org/:_authToken "$NPM_TOKEN" + fi + # Publish with tag if npm publish --tag $tag; then print_message "✅ Successfully published version ${version}" "$GREEN" @@ -34,6 +45,31 @@ publish_version() { fi } +# ─── Non-interactive mode ─────────────────────────────────────────────── +# Usage: ./scripts/publish.sh +# Example: ./scripts/publish.sh 2.4.0-beta.1 beta +# Example: NPM_TOKEN=npm_xxx ./scripts/publish.sh 2.4.0 latest +if [ -n "$1" ] && [ -n "$2" ]; then + version=$1 + tag=$2 + print_message "📋 Non-interactive mode: version=${version}, tag=${tag}" "$YELLOW" + + # Publish + publish_version $version $tag + + # Git operations + git add package.json + git commit -m "chore(release): v${version}" --allow-empty + git tag -a "v$version" -m "Release v$version" + + print_message "🚀 Pushing to repository..." "$YELLOW" + git push && git push --tags + + exit 0 +fi + +# ─── Interactive mode ─────────────────────────────────────────────────── + # Check if the working directory is clean if [ -n "$(git status --porcelain)" ]; then print_message "❌ Working directory is not clean. Please commit or stash your changes first." "$RED" diff --git a/src/handleUpload.ts b/src/handleUpload.ts index 0ba7f35..8d02fdc 100644 --- a/src/handleUpload.ts +++ b/src/handleUpload.ts @@ -7,6 +7,7 @@ import type { CloudinaryVersioningOptions, PublicIDOptions } from "./types"; import path from "path"; import stream from "stream"; import { getResourceType } from "./utils"; +import { sanitizeForPublicID, generatePublicID } from "./publicID"; interface Args { cloudinary: typeof cloudinaryType; @@ -81,9 +82,6 @@ const getUploadOptions = ( } }; -import { sanitizeForPublicID, generatePublicID } from "./publicID"; - - /** * Check if a file is a PDF based on its file extension From c31e313a647c639729314a7eaf33e48d46cd3224 Mon Sep 17 00:00:00 2001 From: Syed Muzamil Date: Tue, 9 Jun 2026 14:30:42 +0530 Subject: [PATCH 3/5] chore(release): v2.4.0-beta.1 From 641673e54c000cd71c48098634e9b6735ca87e12 Mon Sep 17 00:00:00 2001 From: Syed Muzamil Date: Sun, 2 Aug 2026 18:58:49 +0530 Subject: [PATCH 4/5] fix: client upload compiler error --- CHANGELOG.md | 9 +++ src/getClientUploadRoute.ts | 47 ++++++++++++--- src/index.ts | 79 ++++++++++++++----------- src/initClientUploads.ts | 111 ++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 41 deletions(-) create mode 100644 src/initClientUploads.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 358d367..f7cdb04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.4.0-beta.2] - 2026-08-02 + +### Fixed +- **Client uploads compile error**: Stopped importing `initClientUploads` from `@payloadcms/plugin-cloud-storage/utilities`, which is missing in some installed package versions and caused Next.js/Webpack `Attempted import error` failures. +- Vendored `initClientUploads` locally so client-upload endpoint + admin provider registration always works. +- Pass `clientUploads` on the GeneratedAdapter (required for plugin-cloud-storage to skip server `handleUpload` for client-uploaded files). +- Call `initClientUploads` on `incomingConfig` *before* spreading into the returned config, so endpoints/providers are not dropped. +- Add authenticated access checks + collection validation on the signature route (matches official Payload storage adapters). + ## [2.4.0-beta.1] - 2025-06-09 ### Added diff --git a/src/getClientUploadRoute.ts b/src/getClientUploadRoute.ts index 3343159..33c6bbc 100644 --- a/src/getClientUploadRoute.ts +++ b/src/getClientUploadRoute.ts @@ -1,6 +1,8 @@ import { v2 as cloudinary } from 'cloudinary' +import { APIError, Forbidden } from 'payload' import type { PayloadRequest } from 'payload' -import type { PublicIDOptions, CloudinaryVersioningOptions } from './types' +import type { ClientUploadsAccess } from '@payloadcms/plugin-cloud-storage/types' +import type { PublicIDOptions, CloudinaryVersioningOptions, CloudinaryStorageOptions } from './types' import path from 'path' import { getResourceType } from './utils' import { generatePublicID } from './publicID' @@ -10,11 +12,17 @@ export interface GetClientUploadRouteOptions { apiKey: string apiSecret: string folder: string + collections: CloudinaryStorageOptions['collections'] + access?: ClientUploadsAccess publicID?: PublicIDOptions versioning?: CloudinaryVersioningOptions } +const defaultAccess: ClientUploadsAccess = ({ req }) => Boolean(req.user) + export const getClientUploadRoute = (options: GetClientUploadRouteOptions) => { + const access = options.access ?? defaultAccess + return async (req: PayloadRequest) => { // Reconfigure cloudinary here to ensure it uses the correct config for this plugin instance cloudinary.config({ @@ -23,8 +31,28 @@ export const getClientUploadRoute = (options: GetClientUploadRouteOptions) => { api_secret: options.apiSecret, }) - const body = (typeof req.json === 'function' ? await req.json() : req.body) as Record - const { collectionSlug, docPrefix, filename, filesize, mimeType } = body || {} + if (typeof req.json !== 'function') { + throw new APIError('Content-Type expected to be application/json', 400) + } + + const body = (await req.json()) as Record + const { collectionSlug, docPrefix, filename } = body || {} + + if (!collectionSlug || !filename) { + throw new APIError('collectionSlug and filename are required', 400) + } + + const collectionConfig = options.collections[collectionSlug as keyof typeof options.collections] + if (!collectionConfig) { + throw new APIError( + `Collection ${collectionSlug} was not found in Cloudinary storage options`, + 400, + ) + } + + if (!(await access({ collectionSlug, req }))) { + throw new Forbidden() + } try { const timestamp = Math.floor(Date.now() / 1000) @@ -59,7 +87,7 @@ export const getClientUploadRoute = (options: GetClientUploadRouteOptions) => { const signature = cloudinary.utils.api_sign_request( paramsToSign, - options.apiSecret + options.apiSecret, ) // Build the correct upload URL with the specific resource type @@ -77,10 +105,13 @@ export const getClientUploadRoute = (options: GetClientUploadRouteOptions) => { uploadParams: paramsToSign, }) } catch (error) { - return Response.json( - { errors: [{ message: 'Failed to generate upload signature' }] }, - { status: 400 } - ) + if (error instanceof APIError || error instanceof Forbidden) { + throw error + } + + req.payload.logger.error({ err: error }, 'Failed to generate Cloudinary upload signature') + + throw new APIError('Failed to generate upload signature', 400) } } } diff --git a/src/index.ts b/src/index.ts index 9f7aa4c..0b2f4e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,10 +8,10 @@ import type { Config } from "payload"; import { v2 as cloudinary } from "cloudinary"; import { cloudStoragePlugin } from "@payloadcms/plugin-cloud-storage"; -import { initClientUploads } from '@payloadcms/plugin-cloud-storage/utilities'; import type { CloudinaryClientUploadHandlerExtra } from './client/CloudinaryClientUploadHandler'; import { getClientUploadRoute } from './getClientUploadRoute'; +import { initClientUploads } from './initClientUploads'; import path from "path"; @@ -58,15 +58,50 @@ const defaultPDFThumbnailGenerator = ( export const cloudinaryStorage: CloudinaryStoragePlugin = (cloudinaryOptions: CloudinaryStorageOptions) => (incomingConfig: Config): Config => { - if (cloudinaryOptions.enabled === false) { + const isPluginDisabled = cloudinaryOptions.enabled === false; + + // Ensure collections is always an object (defensive handling) + const pluginCollections = cloudinaryOptions.collections || {}; + + // Always register client-upload providers/import-map entries (even when + // disabled) so admin import maps stay consistent across environments. + // Mutate incomingConfig BEFORE spreading it into `config` below. + if (Object.keys(pluginCollections).length > 0) { + initClientUploads< + CloudinaryClientUploadHandlerExtra, + CloudinaryStorageOptions["collections"][string] + >({ + clientHandler: + "payload-cloudinary/client#CloudinaryClientUploadHandler", + collections: pluginCollections, + config: incomingConfig, + enabled: !isPluginDisabled && Boolean(cloudinaryOptions.clientUploads), + extraClientHandlerProps: () => ({ + useCompositePrefixes: !!cloudinaryOptions.useCompositePrefixes, + }), + serverHandler: getClientUploadRoute({ + access: + typeof cloudinaryOptions.clientUploads === "object" + ? cloudinaryOptions.clientUploads.access + : undefined, + apiKey: cloudinaryOptions.config.api_key, + apiSecret: cloudinaryOptions.config.api_secret, + cloudName: cloudinaryOptions.config.cloud_name, + collections: pluginCollections, + folder: cloudinaryOptions.folder || "payload-media", + publicID: cloudinaryOptions.publicID, + versioning: cloudinaryOptions.versioning, + }), + serverHandlerPath: "/cloudinary-client-upload-route", + }); + } + + if (isPluginDisabled) { return incomingConfig; } const adapter = cloudinaryStorageInternal(cloudinaryOptions); - // Ensure collections is always an object (defensive handling) - const pluginCollections = cloudinaryOptions.collections || {}; - // Add adapter to each collection option object const collectionsWithAdapter: CloudStoragePluginOptions["collections"] = Object.entries(pluginCollections).reduce( @@ -80,7 +115,8 @@ export const cloudinaryStorage: CloudinaryStoragePlugin = {} as Record, ); - // Create a new config with our modifications + // Create a new config with our modifications (after initClientUploads so + // endpoints/admin providers added above are included via shallow spread). const config = { ...incomingConfig, collections: (incomingConfig.collections || []).map((collection) => { @@ -180,7 +216,7 @@ export const cloudinaryStorage: CloudinaryStoragePlugin = ...modifiedCollection.hooks, afterChange: [ ...existingAfterChange, - async ({ doc, req, operation }) => { + async ({ doc, req }) => { // Skip if this is an internal update to prevent infinite loop if (req.context?.skipCloudinaryClientUpload) { return doc; @@ -272,38 +308,14 @@ export const cloudinaryStorage: CloudinaryStoragePlugin = }), }; - // Initialize client uploads only if explicitly enabled and collections are configured - if (cloudinaryOptions.clientUploads && Object.keys(pluginCollections).length > 0) { - initClientUploads< - CloudinaryClientUploadHandlerExtra, - CloudinaryStorageOptions["collections"][string] - >({ - clientHandler: - "payload-cloudinary/client#CloudinaryClientUploadHandler", - collections: pluginCollections, - config: incomingConfig, - enabled: Boolean(cloudinaryOptions.clientUploads), - extraClientHandlerProps: () => ({ - useCompositePrefixes: !!cloudinaryOptions.useCompositePrefixes, - }), - serverHandler: getClientUploadRoute({ - apiKey: cloudinaryOptions.config.api_key, - apiSecret: cloudinaryOptions.config.api_secret, - cloudName: cloudinaryOptions.config.cloud_name, - folder: cloudinaryOptions.folder || 'payload-media', - publicID: cloudinaryOptions.publicID, - versioning: cloudinaryOptions.versioning, - }), - serverHandlerPath: "/cloudinary-client-upload-route", - }); - } - return cloudStoragePlugin({ collections: collectionsWithAdapter, + useCompositePrefixes: cloudinaryOptions.useCompositePrefixes, })(config); }; function cloudinaryStorageInternal({ + clientUploads, config, folder = "payload-media", versioning = { @@ -323,6 +335,7 @@ function cloudinaryStorageInternal({ return { name: "cloudinary", + clientUploads, generateURL: getGenerateURL({ config, folder, versioning }), handleDelete: getHandleDelete({ cloudinary, folder }), handleUpload: getHandleUpload({ diff --git a/src/initClientUploads.ts b/src/initClientUploads.ts new file mode 100644 index 0000000..2e38d7d --- /dev/null +++ b/src/initClientUploads.ts @@ -0,0 +1,111 @@ +import type { Config, PayloadHandler } from 'payload' + +/** + * Local copy of `@payloadcms/plugin-cloud-storage`'s `initClientUploads`. + * + * Vendored so client uploads work even when the installed + * `@payloadcms/plugin-cloud-storage` version does not re-export this utility + * (older 3.x builds, or Payload 4 canaries that moved to uploadInstructions). + */ +export const initClientUploads = < + ExtraProps extends Record, + T, +>({ + clientHandler, + collections, + config, + enabled, + extraClientHandlerProps, + serverHandler, + serverHandlerPath, +}: { + /** Path to clientHandler component */ + clientHandler: string + collections: Record + config: Config + enabled: boolean + /** Extra props to pass to the client handler */ + extraClientHandlerProps?: (collection: T) => ExtraProps + serverHandler: PayloadHandler + serverHandlerPath: string +}): void => { + if (enabled) { + if (!config.endpoints) { + config.endpoints = [] + } + + /** + * Tracks how many times the same handler was already applied. + * This allows applying the same plugin multiple times (e.g. different folders). + */ + let handlerCount = 0 + + for (const endpoint of config.endpoints) { + // Match on 'path', 'path-1', 'path-2', etc. + if (endpoint.path?.startsWith(serverHandlerPath)) { + handlerCount++ + } + } + + if (handlerCount) { + serverHandlerPath = `${serverHandlerPath}-${handlerCount}` + } + + config.endpoints.push({ + handler: serverHandler, + method: 'post', + path: serverHandlerPath, + }) + } + + if (!config.admin) { + config.admin = {} + } + + if (!config.admin.dependencies) { + config.admin.dependencies = {} + } + + // Keep the client handler in the import map even when disabled, to avoid + // import map discrepancies between environments. + config.admin.dependencies[clientHandler] = { + type: 'function', + path: clientHandler, + } + + if (!config.admin.components) { + config.admin.components = {} + } + + if (!config.admin.components.providers) { + config.admin.components.providers = [] + } + + for (const collectionSlug in collections) { + const collection = collections[collectionSlug] + + let collectionPrefix: string | undefined + + if ( + collection && + typeof collection === 'object' && + 'prefix' in collection && + typeof collection.prefix === 'string' + ) { + collectionPrefix = collection.prefix + } + + config.admin.components.providers.push({ + clientProps: { + collectionSlug, + enabled, + extra: extraClientHandlerProps + ? extraClientHandlerProps(collection!) + : undefined, + prefix: collectionPrefix, + serverHandlerPath, + }, + path: clientHandler, + }) + } +} From 07ad6eeb35f69c8de8d0c711e4302ea2e39553eb Mon Sep 17 00:00:00 2001 From: Syed Muzamil Date: Sun, 2 Aug 2026 18:59:22 +0530 Subject: [PATCH 5/5] chore(release): v2.4.0-beta.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ea2f37b..15d9cc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload-cloudinary", - "version": "2.4.0-beta.1", + "version": "2.4.0-beta.2", "description": "A Cloudinary storage plugin for Payload CMS", "module": "dist/index.js", "types": "dist/index.d.ts",