Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 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).

## [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.

## [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.)
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,41 @@ cloudinaryStorage({
})
```

### Client-side Uploads

Bypass server limits (like those on Vercel) by uploading files directly from the browser to Cloudinary.

```typescript
// In your payload.config.ts
cloudinaryStorage({
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:
Expand Down Expand Up @@ -502,6 +537,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 |
Expand Down
Binary file modified bun.lockb
Binary file not shown.
20 changes: 13 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -11,7 +11,7 @@
"LICENSE"
],
"scripts": {
"build": "tsc && bun build ./src/index.ts --outdir ./dist --target node",
"build": "tsc && bun build ./src/index.ts --outdir ./dist --target node --external '*'",
"dev": "bun run src/index.ts",
"test": "bun test",
"prepublishOnly": "bun run build",
Expand All @@ -37,16 +37,18 @@
"homepage": "https://github.com/syedmuzamilm/payload-cloudinary#readme",
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.0.0",
"bun-types": "latest"
"typescript": "^5.9.3",
"bun-types": "latest",
"payload": "^3.85.0",
"@payloadcms/plugin-cloud-storage": "^3.85.0"
},
"peerDependencies": {
"typescript": "^5.0.0",
"payload": "^2.0.0"
"payload": "^3.85.0",
"@payloadcms/plugin-cloud-storage": "^3.85.0"
},
"dependencies": {
"@payloadcms/plugin-cloud-storage": "^3.25.0",
"cloudinary": "^2.5.1"
"cloudinary": "^2.10.0"
},
"engines": {
"node": ">=18.0.0"
Expand All @@ -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"
}
}
}
40 changes: 38 additions & 2 deletions scripts/publish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -34,6 +45,31 @@ publish_version() {
fi
}

# ─── Non-interactive mode ───────────────────────────────────────────────
# Usage: ./scripts/publish.sh <version> <tag>
# 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"
Expand Down
114 changes: 114 additions & 0 deletions src/client/CloudinaryClientUploadHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
'use client'

import { createClientUploadHandler } from '@payloadcms/plugin-cloud-storage/client'

export type CloudinaryClientUploadHandlerExtra = {
useCompositePrefixes: boolean
}

export const CloudinaryClientUploadHandler =
createClientUploadHandler<CloudinaryClientUploadHandlerExtra>({
handler: async ({
apiRoute,
collectionSlug,
docPrefix,
extra: { useCompositePrefixes = false },
Comment on lines +14 to +15

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!

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(/^\//, '')
Comment on lines +23 to +25

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(/^\//, '')


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,
}),
})
Comment on lines +30 to +40

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,
}),
})


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

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)
      }


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<string, any>
}

// 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,
}
},
})
Loading
Loading