Skip to content

feat: client uploads#30

Open
SyedMuzamilM wants to merge 3 commits into
mainfrom
feat/client-uploads
Open

feat: client uploads#30
SyedMuzamilM wants to merge 3 commits into
mainfrom
feat/client-uploads

Conversation

@SyedMuzamilM

@SyedMuzamilM SyedMuzamilM commented Jun 9, 2026

Copy link
Copy Markdown
Owner

[2.4.0-beta.1] - 2025-06-09

Added

  • 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.
    • 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.
  • 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.

Summary by CodeRabbit

  • New Features

    • Added client-side uploads to Cloudinary, enabling direct browser-to-Cloudinary file uploads with server-side signature generation for enhanced security.
    • New clientUploads and useCompositePrefixes configuration options.
  • Changed

    • Removed requirement for NEXT_PUBLIC_CLOUDINARY_API_KEY environment variable.
    • Updated dependency versions.
  • Documentation

    • Updated changelog and README with client-side uploads setup guide and new configuration options.

- 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
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements client-side uploads to Cloudinary for Payload CMS, allowing browsers to upload directly to Cloudinary using server-signed credentials. It includes a shared public ID generation module, server route for signature generation, client-side upload handler, refactored server-side upload processing, plugin integration with metadata persistence, and supporting version/dependency updates.

Changes

Client-side Uploads Feature

Layer / File(s) Summary
Configuration types and public ID utilities
src/types.ts, src/publicID.ts
CloudinaryStorageOptions gains clientUploads and useCompositePrefixes options. New publicID.ts module exports sanitizeForPublicID (lowercase normalization and hyphen-based cleanup) and generatePublicID (timestamp/filename-based IDs with extension preservation) used by both client and server paths.
Server route for signature generation
src/getClientUploadRoute.ts
getClientUploadRoute factory returns a route handler that accepts upload metadata, uses shared public ID utilities to compute folder paths and public_id, signs required Cloudinary parameters (timestamp, asset_folder, filename/uniqueness flags, and optional invalidation), and responds with signature, credentials, and signed parameters for direct browser uploads.
Client upload handler orchestration
src/client/CloudinaryClientUploadHandler.ts
CloudinaryClientUploadHandler and CloudinaryClientUploadHandlerExtra type enable browser-to-Cloudinary uploads. The handler fetches signed parameters from the server route, constructs FormData with the file and signatures, uploads to Cloudinary's signed URL, optionally updates the filename, and returns a client upload context with the full response and computed publicId.
Server upload handler refactoring
src/handleUpload.ts
Moves sanitizeForPublicID and generatePublicID to shared publicID.ts module. Refactors metadata computation into an async processUploadResult helper (handles PDF page counts, image/video metadata, version history). Updates getHandleUpload to accept and process clientUploadContext when client uploads bypass Cloudinary, and explicitly sets Cloudinary configuration flags.
Plugin entrypoint and afterChange hook
src/index.ts
Defaults collections to prevent undefined errors. Imports and wires initClientUploads with server route factory. Injects an afterChange hook when clientUploads is enabled that detects client-upload results, prevents infinite recursion via a request context flag, and persists Cloudinary metadata to the document via req.payload.update. Gates initialization to configured collections.
Release and dependency updates
package.json, scripts/publish.sh
Version bumped to 2.4.0-beta.1. Build script adds Bun --external '*' flag. Dependencies aligned: payload and @payloadcms/plugin-cloud-storage to ^3.85.0, cloudinary to ^2.10.0. New ./client subpath export added. Release script now supports non-interactive mode with version/tag arguments and conditional npm auth/versioning.
Documentation
CHANGELOG.md, README.md
Changelog documents 2.4.0-beta.1 changes: client uploads, signature generation, afterChange hook, shared public ID logic, and fixes. README adds client-side uploads section with configuration example and step-by-step workflow; configuration table extended with clientUploads and useCompositePrefixes options.

Sequence Diagram(s)

sequenceDiagram
  participant Browser as Browser/Client
  participant PayloadServer as Payload Server
  participant ServerRoute as getClientUploadRoute
  participant Cloudinary as Cloudinary API
  participant Plugin as Cloudinary Plugin
  Browser->>ServerRoute: POST upload metadata (filename, docPrefix, collectionSlug)
  ServerRoute->>Cloudinary: Reconfigure SDK + sign parameters (timestamp, public_id, asset_folder)
  Cloudinary-->>ServerRoute: Signature
  ServerRoute-->>Browser: JSON (signature, apiKey, uploadUrl, uploadParams, publicId)
  Browser->>Cloudinary: POST file + signed FormData
  Cloudinary-->>Browser: uploadResult
  Browser->>PayloadServer: File upload + clientUploadContext
  PayloadServer->>Plugin: afterChange event
  Plugin->>Plugin: processUploadResult() for metadata
  Plugin->>PayloadServer: Persist doc.cloudinary metadata
  PayloadServer-->>Browser: Updated document
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A rabbit hops from client to cloud,
With signatures signed, all safe and proud,
No secrets exposed to the frontend sand,
Just metadata stored, by the plugin's hand! 🌩️✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: client uploads' directly and concisely summarizes the main feature addition—client-side upload support for Cloudinary—which is the primary focus of the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/client-uploads
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat/client-uploads

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 9, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds client-side Cloudinary uploads to bypass server request-size limits (e.g. Vercel's 4.5 MB cap). It introduces a server-signed credential endpoint, a browser-side upload handler, an afterChange hook for persisting Cloudinary metadata after client uploads, and extracts generatePublicID/sanitizeForPublicID into a shared module.

  • New server route (getClientUploadRoute.ts) generates a signed public_id and Cloudinary upload URL on request, but currently lacks an authentication guard — any unauthenticated caller can obtain valid upload signatures.
  • New client handler (CloudinaryClientUploadHandler.ts) fetches the signature and POSTs the file directly to Cloudinary; the useCompositePrefixes extra-prop is wired up but never acts on any logic in the handler.
  • initClientUploads is called with incomingConfig rather than the already-modified config object, which could cause the server-handler endpoint to be absent from the final returned config.

Confidence Score: 3/5

The client-upload feature has two issues that could prevent it from working correctly in production: the signature endpoint has no auth check, and the config object passed to initClientUploads may not be the same instance that reaches cloudStoragePlugin.

The unauthenticated signature endpoint lets any browser caller upload arbitrary files to the Cloudinary account. The config-object mismatch between initClientUploads and cloudStoragePlugin risks the server-handler route never being registered, silently breaking the entire client-upload flow. These two issues together make the beta unsafe to ship without fixes.

src/getClientUploadRoute.ts (auth guard) and src/index.ts (config object passed to initClientUploads)

Important Files Changed

Filename Overview
src/getClientUploadRoute.ts New server handler for signed upload credentials — missing authentication check, hardcoded use_filename/unique_filename that ignore publicID options, and no input validation for the filename field.
src/client/CloudinaryClientUploadHandler.ts New browser-side upload handler — missing Content-Type header on signature fetch and the useCompositePrefixes prop is accepted but never acts on any logic.
src/index.ts Core plugin wiring — initClientUploads receives the pre-modification incomingConfig instead of the already-mutated config object, which may cause the server-handler endpoint to be missing from the final config.
src/handleUpload.ts Refactored to share processUploadResult logic and support clientUploadContext — correct semantics, but contains leftover debug console.log statements for PDF detection.
src/publicID.ts Clean extraction of generatePublicID and sanitizeForPublicID into a shared module — logic is unchanged from the original in handleUpload.ts.
src/types.ts Added clientUploads and useCompositePrefixes fields to CloudinaryStorageOptions — straightforward type additions.

Sequence Diagram

sequenceDiagram
    participant Browser as Browser (Admin UI)
    participant PayloadAPI as Payload API
    participant SignatureRoute as /cloudinary-client-upload-route
    participant Cloudinary as Cloudinary API
    participant AfterChange as afterChange Hook

    Browser->>PayloadAPI: POST (create document)
    PayloadAPI->>SignatureRoute: "POST {filename, collectionSlug, docPrefix}"
    SignatureRoute-->>PayloadAPI: "{signature, apiKey, uploadUrl, uploadParams}"
    PayloadAPI-->>Browser: signed credentials

    Browser->>Cloudinary: POST FormData (file + signed params)
    Cloudinary-->>Browser: "uploadResult {public_id, secure_url, ...}"

    Browser->>PayloadAPI: "complete create with clientUploadContext={uploadResult}"
    PayloadAPI->>AfterChange: fires with doc + clientUploadContext
    AfterChange->>PayloadAPI: "payload.update({cloudinary: metadata})"
    PayloadAPI-->>Browser: final document with cloudinary metadata
Loading

Comments Outside Diff (1)

  1. src/handleUpload.ts, line 28-66 (link)

    P2 Debug console.log statements left in production code

    getUploadOptions logs a line for every PDF upload detection (lines 29–32) and dumps the full PDF options object as JSON (lines 63–66). These will appear in every serverless function invocation log and may expose internal configuration details. They should be removed or replaced with a proper debug logger before this ships.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "chore(release): v2.4.0-beta.1" | Re-trigger Greptile

Comment on lines +17 to +30
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<string, any>
const { collectionSlug, docPrefix, filename, filesize, mimeType } = body || {}

try {
const timestamp = Math.floor(Date.now() / 1000)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 No authentication check on signature endpoint

The route handler never verifies req.user, so any unauthenticated browser request to /api/cloudinary-client-upload-route can obtain a valid Cloudinary upload signature. An anonymous caller could generate signatures to upload arbitrary content to the configured Cloudinary account, bypassing access controls entirely. Add a check such as if (!req.user) return Response.json({ errors: [{ message: 'Unauthorized' }] }, { status: 401 }) before the try-block.

Comment thread src/index.ts
Comment on lines +276 to +298
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",
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 initClientUploads receives the original config, not the modified one

config is built as a shallow copy of incomingConfig with an entirely new collections array (containing the afterChange hooks and extra fields). initClientUploads is then passed incomingConfig — if the utility assigns new top-level properties (e.g. replaces endpoints with a new array rather than pushing to the existing one), those assignments land on incomingConfig and are not visible in config. The server-handler endpoint would then never be included in the config that cloudStoragePlugin finally returns. Passing config (the already-modified object) here ensures any mutations or reassignments made by initClientUploads are on the same object that gets passed to cloudStoragePlugin.

Comment on lines +47 to +53
const paramsToSign: Record<string, any> = {
timestamp,
public_id: publicIdValue,
asset_folder: folderPath,
use_filename: true,
unique_filename: true,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded use_filename/unique_filename ignore publicID options

generatePublicID correctly consults options.publicID.useFilename and options.publicID.uniqueFilename, but paramsToSign always sets both to true regardless. When those options are false the generated public_id contains no timestamp/filename component, yet the signed params tell Cloudinary the opposite. While Cloudinary gives precedence to the explicit public_id, having signed parameters that contradict the plugin's own config leads to confusing divergence from the server-side upload path.

Suggested change
const paramsToSign: Record<string, any> = {
timestamp,
public_id: publicIdValue,
asset_folder: folderPath,
use_filename: true,
unique_filename: true,
}
const paramsToSign: Record<string, any> = {
timestamp,
public_id: publicIdValue,
asset_folder: folderPath,
use_filename: options.publicID?.useFilename !== false,
unique_filename: options.publicID?.uniqueFilename !== false,
}

Comment on lines +14 to +15
docPrefix,
extra: { useCompositePrefixes = false },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 useCompositePrefixes is received but never used

useCompositePrefixes is destructured from extra and declared in CloudinaryClientUploadHandlerExtra, but it has no effect on the handler's logic. docPrefix is forwarded to the server unconditionally whether or not useCompositePrefixes is true. The README and type definition both advertise it as controlling document-path prefixes, so users who set this option will get no different behavior. Either apply it (e.g. conditionally include docPrefix in the request body) or remove the option and its documentation.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +30 to +40
const signatureResponse = await fetch(endpointRoute, {
method: 'POST',
credentials: 'include',
body: JSON.stringify({
collectionSlug,
docPrefix,
filename: file.name,
filesize: file.size,
mimeType: file.type,
}),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing Content-Type: application/json header on signature request

The body is JSON.stringify(...) but no Content-Type header is set. Some middleware in the Payload/Next.js request pipeline uses the header to decide whether to call the JSON parser. Without it the body may arrive unparsed on the server side, causing filename, collectionSlug, etc. to be undefined and the signature generation to fail silently with a generic error.

Suggested change
const signatureResponse = await fetch(endpointRoute, {
method: 'POST',
credentials: 'include',
body: JSON.stringify({
collectionSlug,
docPrefix,
filename: file.name,
filesize: file.size,
mimeType: file.type,
}),
})
const signatureResponse = await fetch(endpointRoute, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
collectionSlug,
docPrefix,
filename: file.name,
filesize: file.size,
mimeType: file.type,
}),
})

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces direct client-side uploads to Cloudinary to bypass server request limits, featuring secure server-side signature generation, a client-side upload handler, and an afterChange hook to persist metadata. Key feedback includes improving robustness by moving request body parsing inside a try-catch block and validating the filename parameter in the server route, adding defensive guards for potentially undefined parameters in the client handler, and wrapping signature response parsing in a try-catch block. Additionally, the reviewer recommends implementing an isClientUploadsEnabled helper to correctly respect configurations where clientUploads is an object with enabled: false.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +26 to +30
const body = (typeof req.json === 'function' ? await req.json() : req.body) as Record<string, any>
const { collectionSlug, docPrefix, filename, filesize, mimeType } = body || {}

try {
const timestamp = Math.floor(Date.now() / 1000)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Move the request body parsing inside the try-catch block to prevent unhandled exceptions/500 crashes if the request body is empty or contains invalid JSON. Additionally, add explicit validation for the required filename parameter to prevent runtime errors when calling path.extname(filename).

Suggested change
const body = (typeof req.json === 'function' ? await req.json() : req.body) as Record<string, any>
const { collectionSlug, docPrefix, filename, filesize, mimeType } = body || {}
try {
const timestamp = Math.floor(Date.now() / 1000)
try {
const body = (typeof req.json === 'function' ? await req.json() : req.body) as Record<string, any>
const { collectionSlug, docPrefix, filename, filesize, mimeType } = body || {}
if (!filename || typeof filename !== 'string') {
return Response.json(
{ errors: [{ message: 'Missing or invalid required parameter: filename' }] },
{ status: 400 }
)
}
const timestamp = Math.floor(Date.now() / 1000)

Comment on lines +23 to +25
const safeServerURL = serverURL.replace(/\/$/, '')
const safeApiRoute = apiRoute.replace(/^\/|\/$/g, '')
const safeHandlerPath = serverHandlerPath.replace(/^\//, '')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add defensive guards for serverURL, apiRoute, and serverHandlerPath to prevent runtime TypeError crashes if any of these parameters are undefined or null.

Suggested change
const safeServerURL = serverURL.replace(/\/$/, '')
const safeApiRoute = apiRoute.replace(/^\/|\/$/g, '')
const safeHandlerPath = serverHandlerPath.replace(/^\//, '')
const safeServerURL = (serverURL || '').replace(/\/$/, '')
const safeApiRoute = (apiRoute || '').replace(/^\/|\/$/g, '')
const safeHandlerPath = (serverHandlerPath || '').replace(/^\//, '')

Comment on lines +42 to +47
if (!signatureResponse.ok) {
const { errors } = (await signatureResponse.json()) as {
errors: { message: string }[]
}
throw new Error(errors.reduce((acc, err) => `${acc ? `${acc}, ` : ''}${err.message}`, ''))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Improve error handling when fetching the signed upload parameters. If the server returns a non-JSON error or a JSON response without an errors array, the current code will throw a TypeError or SyntaxError. Wrapping the parsing in a try-catch block ensures a robust fallback error message is always thrown.

      if (!signatureResponse.ok) {
        let message = 'Failed to get upload signature'
        try {
          const resJson = await signatureResponse.json()
          if (resJson && Array.isArray(resJson.errors)) {
            message = resJson.errors.map((err: any) => err.message).join(', ')
          }
        } catch {
          try {
            message = await signatureResponse.text()
          } catch {}
        }
        throw new Error(message)
      }

Comment thread src/index.ts
Comment on lines +67 to +68
// Ensure collections is always an object (defensive handling)
const pluginCollections = cloudinaryOptions.collections || {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Define a helper boolean isClientUploadsEnabled to correctly resolve the enabled state when clientUploads is configured as an object (e.g., { enabled: false }). Using Boolean(cloudinaryOptions.clientUploads) directly would incorrectly evaluate to true for any object, even if enabled is set to false.

    // Ensure collections is always an object (defensive handling)
    const pluginCollections = cloudinaryOptions.collections || {};

    const isClientUploadsEnabled = typeof cloudinaryOptions.clientUploads === 'object'
      ? cloudinaryOptions.clientUploads.enabled !== false
      : !!cloudinaryOptions.clientUploads;

Comment thread src/index.ts
// 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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the isClientUploadsEnabled helper to conditionally add the afterChange hook, ensuring it respects { enabled: false } configurations.

Suggested change
if (cloudinaryOptions.clientUploads) {
if (isClientUploadsEnabled) {

Comment thread src/index.ts
Comment on lines +276 to +285
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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the isClientUploadsEnabled helper here to prevent initializing client uploads when clientUploads is configured as an object with enabled: false.

    // Initialize client uploads only if explicitly enabled and collections are configured
    if (isClientUploadsEnabled && Object.keys(pluginCollections).length > 0) {
      initClientUploads<
        CloudinaryClientUploadHandlerExtra,
        CloudinaryStorageOptions["collections"][string]
      >({
        clientHandler:
          "payload-cloudinary/client#CloudinaryClientUploadHandler",
        collections: pluginCollections,
        config: incomingConfig,
        enabled: isClientUploadsEnabled,

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
scripts/publish.sh (1)

48-69: 💤 Low value

Consider quoting variables to prevent word splitting.

The non-interactive mode logic looks good, but consider quoting the $version and $tag variables in function calls to prevent potential issues if they contain spaces or special characters.

🛡️ Proposed fix
-    publish_version $version $tag
+    publish_version "$version" "$tag"

As per coding guidelines, Shellcheck SC2086 recommends quoting variables to prevent globbing and word splitting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/publish.sh` around lines 48 - 69, The script leaves $version and $tag
unquoted which can cause word-splitting/globbing; change the assignments to
version="$1" and tag="$2" and quote uses of these vars when calling functions or
commands (e.g. call publish_version "$version" "$tag" and ensure any other
usages that are currently unquoted are wrapped in quotes) so values with spaces
or special chars are preserved; locate the variables and the publish_version
invocation to apply the fixes.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/getClientUploadRoute.ts`:
- Around line 51-52: The signed client upload params are hardcoded with
use_filename: true and unique_filename: true; change them to honor the same
publicID options used by server uploads by setting use_filename to
publicID?.useFilename !== false and unique_filename to publicID?.uniqueFilename
!== false in the function that builds the signed params (look for
getClientUploadRoute / the object where use_filename and unique_filename are
set) so client-initiated uploads respect publicID configuration consistently
with handleUpload.ts.

In `@src/index.ts`:
- Around line 195-198: The early return that checks doc.cloudinary?.public_id
causes updates to skip applying new uploadResult; change the logic so you only
skip when there is no new upload result instead of any existing cloudinary
metadata—i.e., remove or tighten the guard around doc.cloudinary?.public_id and
ensure the code that reads uploadResult and assigns/overwrites doc.cloudinary
with the new Cloudinary metadata (the uploadResult handling code) runs when
uploadResult is present or when the asset has changed; update the branch that
currently returns early to instead detect absence of uploadResult (or compare
incoming file identifiers) and only return in that case so replacement uploads
update doc.cloudinary.
- Around line 244-257: The update call that writes cloudinaryMetadata via
req.payload.update currently only sets data: { cloudinary: cloudinaryMetadata }
and thus drops version history for client-uploaded collections; modify the
update payload to include the version history when versioning.storeHistory is
enabled (same logic as in handleUpload.ts) by adding data.versions (e.g.,
merging or appending the new cloudinary entry into the existing versions array
or creating a versions array if missing) so that req.payload.update,
cloudinaryMetadata, versioning.storeHistory and data.versions are handled
consistently across both upload paths.
- Around line 178-183: The current afterChange hook (on
modifiedCollection.hooks.afterChange) calls req.payload.update which re-triggers
the collection's afterChange chain and causes duplicate side effects; to fix
this, set a one-time marker on the request context (e.g.,
req.context.skipCloudinaryClientUpload = true) immediately before calling
req.payload.update and clear it after the update so other hooks can detect and
short-circuit (they should check req.context.skipCloudinaryClientUpload), or
alternatively pass a request object that contains that flag into
req.payload.update; reference modifiedCollection.hooks.afterChange, the async
hook function, skipCloudinaryClientUpload, and req.payload.update when making
the change.

In `@src/publicID.ts`:
- Around line 31-36: The generatePublicID call is passing folder and prefix
reversed; update the call site so PublicIDOptions.generatePublicID(filename,
prefix, folder) is called with prefix = path.dirname(folderPath) and folder =
path.basename(folderPath) (i.e., swap the 2nd and 3rd arguments), or
alternatively change the PublicIDOptions signature/README to match the current
order—but the preferred fix is to swap the arguments where generatePublicID is
invoked so its parameters align with the documented (filename, prefix, folder)
semantics.

---

Nitpick comments:
In `@scripts/publish.sh`:
- Around line 48-69: The script leaves $version and $tag unquoted which can
cause word-splitting/globbing; change the assignments to version="$1" and
tag="$2" and quote uses of these vars when calling functions or commands (e.g.
call publish_version "$version" "$tag" and ensure any other usages that are
currently unquoted are wrapped in quotes) so values with spaces or special chars
are preserved; locate the variables and the publish_version invocation to apply
the fixes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9aebe839-5e5d-4904-82df-64869488a14b

📥 Commits

Reviewing files that changed from the base of the PR and between 3ded7b0 and c31e313.

⛔ Files ignored due to path filters (1)
  • bun.lockb is excluded by !**/bun.lockb
📒 Files selected for processing (10)
  • CHANGELOG.md
  • README.md
  • package.json
  • scripts/publish.sh
  • src/client/CloudinaryClientUploadHandler.ts
  • src/getClientUploadRoute.ts
  • src/handleUpload.ts
  • src/index.ts
  • src/publicID.ts
  • src/types.ts

Comment on lines +51 to +52
use_filename: true,
unique_filename: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Respect publicID configuration for filename handling.

The server-side upload handler in handleUpload.ts respects the publicID.useFilename and publicID.uniqueFilename options using publicID?.useFilename !== false and publicID?.uniqueFilename !== false, but these lines hardcode both values to true.

This creates inconsistent behavior: if a user configures publicID: { useFilename: false }, server-initiated uploads will honor it, but client-initiated uploads will not. The signed parameters should match the intended configuration.

🔧 Proposed fix to align with server upload logic
       const paramsToSign: Record<string, any> = {
         timestamp,
         public_id: publicIdValue,
         asset_folder: folderPath,
-        use_filename: true,
-        unique_filename: true,
+        use_filename: options.publicID?.useFilename !== false,
+        unique_filename: options.publicID?.uniqueFilename !== false,
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
use_filename: true,
unique_filename: true,
const paramsToSign: Record<string, any> = {
timestamp,
public_id: publicIdValue,
asset_folder: folderPath,
use_filename: options.publicID?.useFilename !== false,
unique_filename: options.publicID?.uniqueFilename !== false,
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/getClientUploadRoute.ts` around lines 51 - 52, The signed client upload
params are hardcoded with use_filename: true and unique_filename: true; change
them to honor the same publicID options used by server uploads by setting
use_filename to publicID?.useFilename !== false and unique_filename to
publicID?.uniqueFilename !== false in the function that builds the signed params
(look for getClientUploadRoute / the object where use_filename and
unique_filename are set) so client-initiated uploads respect publicID
configuration consistently with handleUpload.ts.

Comment thread src/index.ts
Comment on lines +178 to +183
const existingAfterChange = modifiedCollection.hooks?.afterChange || [];
modifiedCollection.hooks = {
...modifiedCollection.hooks,
afterChange: [
...existingAfterChange,
async ({ doc, req, operation }) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Avoid re-entering the collection’s full afterChange chain here.

This hook appends itself after existingAfterChange, then performs req.payload.update(...) inside afterChange. That second write will invoke the collection’s other afterChange hooks again; only this plugin hook checks skipCloudinaryClientUpload, so user hooks/webhooks/side effects will run twice for one client upload.

Also applies to: 250-257

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/index.ts` around lines 178 - 183, The current afterChange hook (on
modifiedCollection.hooks.afterChange) calls req.payload.update which re-triggers
the collection's afterChange chain and causes duplicate side effects; to fix
this, set a one-time marker on the request context (e.g.,
req.context.skipCloudinaryClientUpload = true) immediately before calling
req.payload.update and clear it after the update so other hooks can detect and
short-circuit (they should check req.context.skipCloudinaryClientUpload), or
alternatively pass a request object that contains that flag into
req.payload.update; reference modifiedCollection.hooks.afterChange, the async
hook function, skipCloudinaryClientUpload, and req.payload.update when making
the change.

Comment thread src/index.ts
Comment on lines +195 to +198
// Already has cloudinary metadata (e.g., from a prior hook run)
if (doc.cloudinary?.public_id) {
return doc;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t skip metadata refresh when replacing an existing asset.

This early return makes client-upload replacements keep the old cloudinary block. Any update against a document that already has cloudinary.public_id bails out before applying the new uploadResult, so the stored Cloudinary metadata can become stale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/index.ts` around lines 195 - 198, The early return that checks
doc.cloudinary?.public_id causes updates to skip applying new uploadResult;
change the logic so you only skip when there is no new upload result instead of
any existing cloudinary metadata—i.e., remove or tighten the guard around
doc.cloudinary?.public_id and ensure the code that reads uploadResult and
assigns/overwrites doc.cloudinary with the new Cloudinary metadata (the
uploadResult handling code) runs when uploadResult is present or when the asset
has changed; update the branch that currently returns early to instead detect
absence of uploadResult (or compare incoming file identifiers) and only return
in that case so replacement uploads update doc.cloudinary.

Comment thread src/index.ts
Comment on lines +244 to +257
// 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,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Persist version history on the client-upload path too.

src/handleUpload.ts still appends data.versions when versioning.storeHistory is enabled, but this fallback only writes cloudinary. Collections using client uploads will silently stop recording Cloudinary version history, so history/rollback data becomes incomplete.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/index.ts` around lines 244 - 257, The update call that writes
cloudinaryMetadata via req.payload.update currently only sets data: {
cloudinary: cloudinaryMetadata } and thus drops version history for
client-uploaded collections; modify the update payload to include the version
history when versioning.storeHistory is enabled (same logic as in
handleUpload.ts) by adding data.versions (e.g., merging or appending the new
cloudinary entry into the existing versions array or creating a versions array
if missing) so that req.payload.update, cloudinaryMetadata,
versioning.storeHistory and data.versions are handled consistently across both
upload paths.

Comment thread src/publicID.ts
Comment on lines +31 to +36
if (publicIDOptions?.generatePublicID) {
return publicIDOptions.generatePublicID(
filename,
path.dirname(folderPath),
path.basename(folderPath),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify if there are any existing custom generatePublicID implementations that might break

rg -n -C3 'generatePublicID.*:.*\(' --type=ts

Repository: SyedMuzamilM/payload-cloudinary

Length of output: 367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the callback type + its documented parameter meaning
sed -n '70,140p' src/types.ts

# Show the call site in src/publicID.ts around the reported lines
sed -n '1,90p' src/publicID.ts

# Find all call sites / exports of generatePublicID usage
rg -n 'generatePublicID' src

Repository: SyedMuzamilM/payload-cloudinary

Length of output: 5061


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,120p' src/getClientUploadRoute.ts
sed -n '1,220p' src/handleUpload.ts

# Also locate how folderPath is formed in the storage options/config
rg -n "folderPath" src -S
rg -n "publicIDOptions|publicID" src/handleUpload.ts src/getClientUploadRoute.ts src -S

Repository: SyedMuzamilM/payload-cloudinary

Length of output: 13635


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for any documentation/examples referencing generatePublicID
rg -n "generatePublicID" -S . \
  || true

# If repo has README/docs, search there too
ls -la
fd -a -t f 'README*|docs*|*.md' . | head

# Show any block where PublicIDOptions/custom generator is described
rg -n "PublicIDOptions|publicID|prefix\\?:|folder\\?:|`@param` prefix|`@param` folder" src -S

Repository: SyedMuzamilM/payload-cloudinary

Length of output: 2637


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show README example around the generatePublicID signature
sed -n '120,190p' README.md

# Show README table row for publicID.generatePublicID
sed -n '520,580p' README.md

Repository: SyedMuzamilM/payload-cloudinary

Length of output: 4731


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the exact section describing generatePublicID callback params
rg -n "generatePublicID:|publicID\\.generatePublicID|prefix, folder|\\(filename, prefix, folder\\)" README.md

# Grab more context around the snippet we saw
sed -n '130,180p' README.md

# Also check if README defines "folder" vs "prefix" elsewhere
rg -n "docPrefix|useCompositePrefixes|prefix.*folder" README.md

Repository: SyedMuzamilM/payload-cloudinary

Length of output: 1950


Fix generatePublicID callback argument semantics (prefix vs folder).
PublicIDOptions.generatePublicID is documented/used as (filename, prefix, folder) where folder is the base folder and prefix is the (doc/file) prefix, but src/publicID.ts calls it as generatePublicID(filename, path.dirname(folderPath), path.basename(folderPath)) (so for folderPath = <baseFolder>/<prefixSegment>, the callback receives prefix=<baseFolder> and folder=<prefixSegment>).
Swap the 2nd/3rd arguments (or adjust PublicIDOptions + README to match the current behavior).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/publicID.ts` around lines 31 - 36, The generatePublicID call is passing
folder and prefix reversed; update the call site so
PublicIDOptions.generatePublicID(filename, prefix, folder) is called with prefix
= path.dirname(folderPath) and folder = path.basename(folderPath) (i.e., swap
the 2nd and 3rd arguments), or alternatively change the PublicIDOptions
signature/README to match the current order—but the preferred fix is to swap the
arguments where generatePublicID is invoked so its parameters align with the
documented (filename, prefix, folder) semantics.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant