-
Notifications
You must be signed in to change notification settings - Fork 7
feat: client uploads #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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.) |
| 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 }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add defensive guards for
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The body is
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Improve error handling when fetching the signed upload parameters. If the server returns a non-JSON error or a JSON response without an 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, | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
useCompositePrefixesis received but never useduseCompositePrefixesis destructured fromextraand declared inCloudinaryClientUploadHandlerExtra, but it has no effect on the handler's logic.docPrefixis forwarded to the server unconditionally whether or notuseCompositePrefixesistrue. 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 includedocPrefixin 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!