From 96ee08ac9c65371124cd937f756a8896d4c4dcef Mon Sep 17 00:00:00 2001 From: Ryan Woodcox Date: Fri, 23 Jan 2026 09:59:17 -0500 Subject: [PATCH] Add uploadPreset support for Cloudinary upload presets Add the uploadPreset parameter across all upload flows to enable Cloudinary upload presets with signed uploads. Upload presets allow pre-configured settings (folder, transformations, moderation, etc.) to be enforced server-side. Changes: - Add uploadPreset to CloudinaryUploadOptions interface - Include upload_preset in signature generation for signed uploads - Add uploadPreset to upload and generateUploadCredentials actions - Update client UploadOptions and DirectUploadOptions interfaces - Add uploadPreset to makeCloudinaryAPI validators and return types - Update CloudinaryAPI type in React hooks - Add tests for uploadPreset parameter acceptance - Document upload options including uploadPreset in README --- README.md | 16 ++++++++++++++++ src/client/index.ts | 5 +++++ src/client/upload-utils.ts | 1 + src/component/apiUtils.ts | 5 +++++ src/component/lib.test.ts | 35 +++++++++++++++++++++++++++++++++++ src/component/lib.ts | 5 +++++ src/react/index.tsx | 1 + 7 files changed, 68 insertions(+) diff --git a/README.md b/README.md index b045e17..4868850 100644 --- a/README.md +++ b/README.md @@ -496,6 +496,21 @@ export const { } = makeCloudinaryAPI(components.cloudinary); ``` +### Upload Options + +All upload methods (`upload`, `generateUploadCredentials`, `uploadDirect`) accept these options: + +| Option | Type | Description | +| -------------- | ---------- | --------------------------------------------------------------------------- | +| `folder` | `string` | Cloudinary folder path (e.g., `"uploads/avatars"`) | +| `tags` | `string[]` | Tags for organization and filtering | +| `publicId` | `string` | Custom public ID (auto-generated if not provided) | +| `uploadPreset` | `string` | [Upload preset](https://cloudinary.com/documentation/upload_presets) name | +| `userId` | `string` | User ID for tracking ownership | +| `transformation` | `object` | Eager transformation applied during upload | + +**Upload Presets** allow you to define upload options centrally in Cloudinary (folder, transformations, moderation, etc.) and apply them by name. When using signed uploads, the preset must be configured as "signed" in Cloudinary. + ### React Implementation ```tsx @@ -521,6 +536,7 @@ function LargeFileUpload() { const credentials = await getCredentials({ folder: "large-uploads", tags: ["user-upload"], + uploadPreset: "my-preset", // Optional: apply a Cloudinary upload preset }); // Step 2: Upload directly to Cloudinary with progress tracking diff --git a/src/client/index.ts b/src/client/index.ts index ef9aff8..be6d00f 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -69,6 +69,7 @@ export interface UploadOptions { transformation?: CloudinaryTransformation; publicId?: string; userId?: string; + uploadPreset?: string; } export interface ListAssetsOptions { @@ -656,6 +657,7 @@ export class CloudinaryClient { transformation: v.optional(vTransformation), publicId: v.optional(v.string()), userId: v.optional(v.string()), + uploadPreset: v.optional(v.string()), }, returns: vUploadResult, handler: async (ctx, args) => { @@ -987,6 +989,7 @@ export function makeCloudinaryAPI( transformation: v.optional(vTransformation), publicId: v.optional(v.string()), userId: v.optional(v.string()), + uploadPreset: v.optional(v.string()), }, returns: vUploadResult, handler: async (ctx, args) => { @@ -1117,6 +1120,7 @@ export function makeCloudinaryAPI( transformation: v.optional(vTransformation), publicId: v.optional(v.string()), userId: v.optional(v.string()), + uploadPreset: v.optional(v.string()), }, returns: v.object({ uploadUrl: v.string(), @@ -1128,6 +1132,7 @@ export function makeCloudinaryAPI( tags: v.optional(v.string()), transformation: v.optional(v.string()), public_id: v.optional(v.string()), + upload_preset: v.optional(v.string()), }), }), handler: async (ctx, args) => { diff --git a/src/client/upload-utils.ts b/src/client/upload-utils.ts index aedab65..1d626b4 100644 --- a/src/client/upload-utils.ts +++ b/src/client/upload-utils.ts @@ -22,6 +22,7 @@ export interface DirectUploadOptions { transformation?: CloudinaryTransformation; publicId?: string; userId?: string; + uploadPreset?: string; } // Re-export types for convenience diff --git a/src/component/apiUtils.ts b/src/component/apiUtils.ts index 0d5d1dc..439eb3b 100644 --- a/src/component/apiUtils.ts +++ b/src/component/apiUtils.ts @@ -527,6 +527,7 @@ export interface CloudinaryUploadOptions { publicId?: string; userId?: string; eager?: CloudinaryTransformation[]; + uploadPreset?: string; } // Generate authentication signature for Cloudinary API @@ -676,6 +677,7 @@ export async function uploadToCloudinary( if (options.eager && options.eager.length > 0) { uploadParams.eager = eagerToString(options.eager); } + if (options.uploadPreset) uploadParams.upload_preset = options.uploadPreset; // Generate signature const { signature, timestamp } = await generateSignature( @@ -859,6 +861,7 @@ export async function generateDirectUploadCredentials( transformation?: CloudinaryTransformation; publicId?: string; resourceType?: string; + uploadPreset?: string; } = {} ): Promise { const uploadUrl = `https://api.cloudinary.com/v1_1/${cloudName}/${options.resourceType || "image"}/upload`; @@ -877,6 +880,7 @@ export async function generateDirectUploadCredentials( paramsToSign.transformation = transformationString; } } + if (options.uploadPreset) paramsToSign.upload_preset = options.uploadPreset; // Generate signature (without api_key) const { signature, timestamp } = await generateDirectUploadSignature( @@ -903,6 +907,7 @@ export async function generateDirectUploadCredentials( uploadParams.transformation = transformationString; } } + if (options.uploadPreset) uploadParams.upload_preset = options.uploadPreset; return { uploadUrl, diff --git a/src/component/lib.test.ts b/src/component/lib.test.ts index bc0e268..85a7dda 100644 --- a/src/component/lib.test.ts +++ b/src/component/lib.test.ts @@ -198,6 +198,41 @@ describe("Cloudinary component lib", () => { } }); + // Test uploadPreset is accepted in upload action + test("should accept uploadPreset in upload action", async () => { + const t = convexTest(schema, modules); + + // uploadPreset should be accepted without validation errors + const result = await t.action(api.lib.upload, { + base64Data: mockImageBase64, + filename: "test.png", + uploadPreset: "my-preset", + config: mockConfig, + }); + + // The upload will fail due to mock credentials, but the error should NOT be about uploadPreset + if (!result.success && result.error) { + expect(result.error).not.toContain("uploadPreset"); + expect(result.error).not.toContain("upload_preset"); + } + }); + + // Test generateUploadCredentials includes uploadPreset + test("should include upload_preset in generated credentials", async () => { + const t = convexTest(schema, modules); + + const result = await t.action(api.lib.generateUploadCredentials, { + folder: "test-folder", + uploadPreset: "my-preset", + config: mockConfig, + }); + + expect(result.uploadUrl).toContain("test-cloud"); + expect(result.uploadParams.upload_preset).toBe("my-preset"); + expect(result.uploadParams.folder).toBe("test-folder"); + expect(result.uploadParams.signature).toBeDefined(); + }); + // Note: Integration tests that require real Cloudinary credentials are skipped. // These tests would need real credentials to run: // - should upload and store asset diff --git a/src/component/lib.ts b/src/component/lib.ts index 3fad113..ccf1b25 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -1099,6 +1099,7 @@ export const upload = action({ transformation: v.optional(vTransformation), publicId: v.optional(v.string()), userId: v.optional(v.string()), + uploadPreset: v.optional(v.string()), config: v.object({ cloudName: v.string(), apiKey: v.string(), @@ -1147,6 +1148,7 @@ export const upload = action({ tags: args.tags, publicId: args.publicId, userId: args.userId, + uploadPreset: args.uploadPreset, }; if (args.transformation) { @@ -1270,6 +1272,7 @@ export const generateUploadCredentials = action({ transformation: v.optional(vTransformation), publicId: v.optional(v.string()), userId: v.optional(v.string()), + uploadPreset: v.optional(v.string()), config: v.object({ cloudName: v.string(), apiKey: v.string(), @@ -1286,6 +1289,7 @@ export const generateUploadCredentials = action({ tags: v.optional(v.string()), transformation: v.optional(v.string()), public_id: v.optional(v.string()), + upload_preset: v.optional(v.string()), }), }), handler: async (ctx, args) => { @@ -1319,6 +1323,7 @@ export const generateUploadCredentials = action({ tags: args.tags, transformation: args.transformation, publicId: args.publicId, + uploadPreset: args.uploadPreset, } ); diff --git a/src/react/index.tsx b/src/react/index.tsx index 9eed0d2..9ad019b 100644 --- a/src/react/index.tsx +++ b/src/react/index.tsx @@ -54,6 +54,7 @@ export interface CloudinaryAPI { transformation?: CloudinaryTransformation; publicId?: string; userId?: string; + uploadPreset?: string; }, UploadResult >;