diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f7cdb04 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,63 @@ +# 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.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 +- **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.) diff --git a/README.md b/README.md index b0f9847..1569895 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 | diff --git a/bun.lockb b/bun.lockb index 570ef51..2c7fae7 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 18a33f1..15d9cc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload-cloudinary", - "version": "2.3.0", + "version": "2.4.0-beta.2", "description": "A Cloudinary storage plugin for Payload CMS", "module": "dist/index.js", "types": "dist/index.d.ts", @@ -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", @@ -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" @@ -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/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/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..33c6bbc --- /dev/null +++ b/src/getClientUploadRoute.ts @@ -0,0 +1,117 @@ +import { v2 as cloudinary } from 'cloudinary' +import { APIError, Forbidden } from 'payload' +import type { PayloadRequest } from 'payload' +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' + +export interface GetClientUploadRouteOptions { + cloudName: string + 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({ + cloud_name: options.cloudName, + api_key: options.apiKey, + api_secret: options.apiSecret, + }) + + 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) + + // 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) { + 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/handleUpload.ts b/src/handleUpload.ts index c3d4f6e..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,80 +82,6 @@ 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), - ); - } - - // 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 +125,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 +138,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 +231,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..0b2f4e6 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 type { CloudinaryClientUploadHandlerExtra } from './client/CloudinaryClientUploadHandler'; +import { getClientUploadRoute } from './getClientUploadRoute'; +import { initClientUploads } from './initClientUploads'; + import path from "path"; import { getGenerateURL } from "./generateURL"; @@ -53,7 +58,45 @@ 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; } @@ -61,7 +104,7 @@ export const cloudinaryStorage: CloudinaryStoragePlugin = // 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]: { @@ -72,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) => { @@ -161,16 +205,117 @@ 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 }) => { + // 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; }), }; return cloudStoragePlugin({ collections: collectionsWithAdapter, + useCompositePrefixes: cloudinaryOptions.useCompositePrefixes, })(config); }; function cloudinaryStorageInternal({ + clientUploads, config, folder = "payload-media", versioning = { @@ -190,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, + }) + } +} 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 */