From 2a82dd53432b558850d867d5e80b183a85a444bc Mon Sep 17 00:00:00 2001 From: Tal Lev-Ami Date: Sun, 8 Mar 2026 09:59:05 +0200 Subject: [PATCH 1/6] feat: accept in-memory upload inputs --- lib/uploader.js | 125 ++++++++++++++++-- .../integration/api/uploader/uploader_spec.js | 75 +++++++++++ types/cloudinary_ts_spec.ts | 12 ++ types/index.d.ts | 10 +- 4 files changed, 208 insertions(+), 14 deletions(-) diff --git a/lib/uploader.js b/lib/uploader.js index 7f224ff9..46cf7e91 100644 --- a/lib/uploader.js +++ b/lib/uploader.js @@ -52,11 +52,92 @@ exports.unsigned_upload = function unsigned_upload(file, upload_preset, callback }; exports.upload = function upload(file, callback, options = {}) { + callback = typeof callback === "function" ? callback : function () { + }; + + if (isBlob(file)) { + let uploadSourcePromise = getBlobArrayBuffer(file).then((arrayBuffer) => toUploadSource(Buffer.from(arrayBuffer), options, { + contentType: file.type, + filename: file.name + })); + + if (options.disable_promises) { + uploadSourcePromise.then((uploadSource) => call_upload_api(uploadSource, callback, options)).catch((error) => callback({ + error + })); + return; + } + + return uploadSourcePromise.then((uploadSource) => call_upload_api(uploadSource, callback, options)).catch((error) => { + callback({ + error + }); + throw error; + }); + } + + if (isUploadData(file)) { + file = toUploadSource(file, options); + } + + return call_upload_api(file, callback, options); +}; + +function call_upload_api(file, callback, options) { return call_api("upload", callback, options, function () { let params = build_upload_params(options); return isRemoteUrl(file) ? [params, { file: file }] : [params, {}, file]; }); -}; +} + +function isUint8Array(file) { + return typeof Uint8Array !== "undefined" && file instanceof Uint8Array; +} + +function isArrayBuffer(file) { + return typeof ArrayBuffer !== "undefined" && file instanceof ArrayBuffer; +} + +function isBlob(file) { + return typeof Blob !== "undefined" && file instanceof Blob; +} + +function getBlobArrayBuffer(file) { + if (typeof file.arrayBuffer === "function") { + return file.arrayBuffer(); + } + + if (typeof FileReader !== "undefined") { + return new Promise((resolve, reject) => { + let reader = new FileReader(); + reader.onload = function () { + resolve(reader.result); + }; + reader.onerror = function () { + reject(reader.error || new Error("Failed to read the Blob")); + }; + reader.readAsArrayBuffer(file); + }); + } + + return Promise.reject(new Error("Blob upload requires Blob.arrayBuffer() or FileReader support")); +} + +function isUploadData(file) { + return Buffer.isBuffer(file) || isUint8Array(file) || isArrayBuffer(file); +} + +function isUploadSource(file) { + return isObject(file) && Buffer.isBuffer(file.data); +} + +function toUploadSource(file, options = {}, extra = {}) { + return { + data: Buffer.isBuffer(file) ? file : Buffer.from(file), + filename: options.filename || extra.filename || "file", + contentType: extra.contentType || "application/octet-stream" + }; +} exports.upload_large = function upload_large(path, callback, options = {}) { if ((path != null) && isRemoteUrl(path)) { @@ -566,9 +647,8 @@ function post(url, post_data, boundary, file, callback, options) { let finish_buffer = Buffer.from("--" + boundary + "--", 'ascii'); let oauth_token = options.oauth_token || config().oauth_token; if ((file != null) || options.stream) { - // eslint-disable-next-line no-nested-ternary - let filename = options.stream ? options.filename ? options.filename : "file" : basename(file); - file_header = Buffer.from(encodeFilePart(boundary, 'application/octet-stream', 'file', filename), 'binary'); + let { filename, contentType } = getFileUploadOptions(file, options); + file_header = Buffer.from(encodeFilePart(boundary, contentType, 'file', filename), 'binary'); } const parsedUrl = new URL(url); let post_options = { @@ -643,12 +723,16 @@ function post(url, post_data, boundary, file, callback, options) { } if (file != null) { post_request.write(file_header); - fs.createReadStream(file).on('error', function (error) { - callback({ - error: error - }); - return post_request.abort(); - }).pipe(upload_stream); + if (isUploadSource(file)) { + upload_stream.end(file.data); + } else { + fs.createReadStream(file).on('error', function (error) { + callback({ + error: error + }); + return post_request.abort(); + }).pipe(upload_stream); + } } else { post_request.write(finish_buffer); post_request.end(); @@ -656,6 +740,27 @@ function post(url, post_data, boundary, file, callback, options) { return true; } +function getFileUploadOptions(file, options = {}) { + if (options.stream) { + return { + filename: options.filename || "file", + contentType: "application/octet-stream" + }; + } + + if (isUploadSource(file)) { + return { + filename: file.filename || "file", + contentType: file.contentType || "application/octet-stream" + }; + } + + return { + filename: basename(file), + contentType: "application/octet-stream" + }; +} + function encodeFieldPart(boundary, name, value) { return [ `--${boundary}\r\n`, diff --git a/test/integration/api/uploader/uploader_spec.js b/test/integration/api/uploader/uploader_spec.js index 33268532..76927aab 100644 --- a/test/integration/api/uploader/uploader_spec.js +++ b/test/integration/api/uploader/uploader_spec.js @@ -95,6 +95,81 @@ describe("uploader", function () { expect(result.signature).to.eql(expected_signature); }); }); + describe("in-memory uploads", function () { + it("should successfully upload a Buffer", function () { + const buffer = fs.readFileSync(IMAGE_FILE); + return cloudinary.v2.uploader.upload(buffer, { + tags: UPLOAD_TAGS + }).then(function (result) { + expect(result.width).to.eql(241); + expect(result.height).to.eql(51); + expect(result.format).to.eql("png"); + }); + }); + + it("should successfully upload a Uint8Array", function () { + const uint8Array = new Uint8Array(fs.readFileSync(IMAGE_FILE)); + return cloudinary.v2.uploader.upload(uint8Array, { + tags: UPLOAD_TAGS + }).then(function (result) { + expect(result.width).to.eql(241); + expect(result.height).to.eql(51); + expect(result.format).to.eql("png"); + }); + }); + + it("should successfully upload an ArrayBuffer", function () { + const buffer = fs.readFileSync(IMAGE_FILE); + const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + return cloudinary.v2.uploader.upload(arrayBuffer, { + tags: UPLOAD_TAGS + }).then(function (result) { + expect(result.width).to.eql(241); + expect(result.height).to.eql(51); + expect(result.format).to.eql("png"); + }); + }); + + it("should successfully upload a Blob", function () { + const buffer = fs.readFileSync(IMAGE_FILE); + const blob = new Blob([buffer], { type: "image/png" }); + return cloudinary.v2.uploader.upload(blob, { + tags: UPLOAD_TAGS + }).then(function (result) { + expect(result.width).to.eql(241); + expect(result.height).to.eql(51); + expect(result.format).to.eql("png"); + }); + }); + + it("should upload a raw buffer when resource_type is raw", function () { + const buffer = fs.readFileSync(RAW_FILE); + return cloudinary.v2.uploader.upload(buffer, { + resource_type: "raw", + tags: UPLOAD_TAGS + }).then(function (result) { + expect(result.resource_type).to.eql("raw"); + }); + }); + + it("should send buffer uploads without reading from the filesystem", function () { + const buffer = fs.readFileSync(IMAGE_FILE); + return helper.provideMockObjects(async function (mockXHR, writeSpy) { + const createReadStreamSpy = sinon.spy(fs, "createReadStream"); + try { + await cloudinary.v2.uploader.upload(buffer, { + filename: "buffer-upload.png", + tags: UPLOAD_TAGS + }).catch(helper.ignoreApiFailure); + sinon.assert.notCalled(createReadStreamSpy); + sinon.assert.calledWith(writeSpy, sinon.match((arg) => Buffer.isBuffer(arg) && arg.equals(buffer))); + sinon.assert.calledWith(writeSpy, sinon.match((arg) => Buffer.isBuffer(arg) && arg.toString("utf8").includes('filename="buffer-upload.png"'))); + } finally { + createReadStreamSpy.restore(); + } + }); + }); + }); it("should successfully upload with metadata", function () { return helper.provideMockObjects(async function (mockXHR, writeSpy, requestSpy) { await uploadImage({ metadata: METADATA_SAMPLE_DATA }).catch(helper.ignoreApiFailure); diff --git a/types/cloudinary_ts_spec.ts b/types/cloudinary_ts_spec.ts index 6fead38a..09132700 100644 --- a/types/cloudinary_ts_spec.ts +++ b/types/cloudinary_ts_spec.ts @@ -900,6 +900,18 @@ cloudinary.v2.uploader.upload("ftp://user1:mypass@ftp.example.com/sample.jpg", console.log(result, error); }); +// $ExpectType Promise +cloudinary.v2.uploader.upload(Buffer.from("sample")); + +// $ExpectType Promise +cloudinary.v2.uploader.upload(new Uint8Array([1, 2, 3])); + +// $ExpectType Promise +cloudinary.v2.uploader.upload(new ArrayBuffer(8)); + +// $ExpectType Promise +cloudinary.v2.uploader.upload(new Blob(["sample"], {type: "text/plain"})); + // $ExpectType Promise | UploadStream cloudinary.v2.uploader.upload_large("my_large_video.mp4", { diff --git a/types/index.d.ts b/types/index.d.ts index e212d98d..9f7b975d 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1285,6 +1285,8 @@ declare module 'cloudinary' { /****************************** Upload API V2 Methods *************************************/ namespace uploader { + type UploadFile = string | Buffer | Uint8Array | ArrayBuffer | Blob; + function add_context(context: string, public_ids: string[], options?: { type?: DeliveryType, resource_type?: ResourceType @@ -1394,17 +1396,17 @@ declare module 'cloudinary' { function unsigned_image_upload_tag(field: string, upload_preset: string, options?: UploadApiOptions): Promise; - function unsigned_upload(file: string, upload_preset: string, options?: UploadApiOptions, callback?: ResponseCallback): Promise; + function unsigned_upload(file: UploadFile, upload_preset: string, options?: UploadApiOptions, callback?: ResponseCallback): Promise; - function unsigned_upload(file: string, upload_preset: string, callback?: ResponseCallback): Promise; + function unsigned_upload(file: UploadFile, upload_preset: string, callback?: ResponseCallback): Promise; function unsigned_upload_stream(upload_preset: string, options?: UploadApiOptions, callback?: ResponseCallback): UploadStream; function unsigned_upload_stream(upload_preset: string, callback?: ResponseCallback): UploadStream; - function upload(file: string, options?: UploadApiOptions, callback?: UploadResponseCallback): Promise; + function upload(file: UploadFile, options?: UploadApiOptions, callback?: UploadResponseCallback): Promise; - function upload(file: string, callback?: UploadResponseCallback): Promise; + function upload(file: UploadFile, callback?: UploadResponseCallback): Promise; function upload_chunked(path: string, options?: UploadApiOptions, callback?: UploadResponseCallback): UploadStream; From a8eea2f2bb213b6b3ca35959f825582a61b124e7 Mon Sep 17 00:00:00 2001 From: Tal Lev-Ami Date: Sun, 8 Mar 2026 11:03:41 +0200 Subject: [PATCH 2/6] fix: support legacy blob implementations --- lib/uploader.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lib/uploader.js b/lib/uploader.js index 46cf7e91..7c1264cd 100644 --- a/lib/uploader.js +++ b/lib/uploader.js @@ -107,6 +107,11 @@ function getBlobArrayBuffer(file) { return file.arrayBuffer(); } + let blobBuffer = getBlobBuffer(file); + if (blobBuffer) { + return Promise.resolve(blobBuffer.buffer.slice(blobBuffer.byteOffset, blobBuffer.byteOffset + blobBuffer.byteLength)); + } + if (typeof FileReader !== "undefined") { return new Promise((resolve, reject) => { let reader = new FileReader(); @@ -123,6 +128,21 @@ function getBlobArrayBuffer(file) { return Promise.reject(new Error("Blob upload requires Blob.arrayBuffer() or FileReader support")); } +function getBlobBuffer(file) { + if (typeof Object.getOwnPropertySymbols !== "function") { + return null; + } + + for (let symbol of Object.getOwnPropertySymbols(file)) { + let implementation = file[symbol]; + if (implementation && Buffer.isBuffer(implementation._buffer)) { + return implementation._buffer; + } + } + + return null; +} + function isUploadData(file) { return Buffer.isBuffer(file) || isUint8Array(file) || isArrayBuffer(file); } From d277990e901e99824650f130c6dc08d188024abe Mon Sep 17 00:00:00 2001 From: Tal Lev-Ami Date: Wed, 30 Sep 2026 11:13:26 +0200 Subject: [PATCH 3/6] fix: call the upload callback one time when a Blob upload fails In promise mode, call_api reports a failure to the callback and also rejects. The Blob branch then called the callback again from its .catch. Now the Blob branch calls the callback only for a failure before call_api returns. Examples of such a failure are a Blob read error or a synchronous throw. Add a unit test that sends a mocked HTTP 500 response. --- lib/uploader.js | 32 +++++++++++-------- test/unit/upload_blob_spec.js | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 test/unit/upload_blob_spec.js diff --git a/lib/uploader.js b/lib/uploader.js index 859c8e7c..beba582d 100644 --- a/lib/uploader.js +++ b/lib/uploader.js @@ -56,24 +56,30 @@ exports.upload = function upload(file, callback, options = {}) { }; if (isBlob(file)) { - let uploadSourcePromise = getBlobArrayBuffer(file).then((arrayBuffer) => toUploadSource(Buffer.from(arrayBuffer), options, { - contentType: file.type, - filename: file.name - })); + // After call_api returns, call_api reports its own failures. Do not call the callback again. + let apiCalled = false; + let uploadSourcePromise = Promise.resolve().then(() => getBlobArrayBuffer(file)).then((arrayBuffer) => { + let result = call_upload_api(toUploadSource(Buffer.from(arrayBuffer), options, { + contentType: file.type, + filename: file.name + }), callback, options); + apiCalled = true; + return result; + }).catch((error) => { + if (!apiCalled) { + callback({ + error + }); + } + throw error; + }); if (options.disable_promises) { - uploadSourcePromise.then((uploadSource) => call_upload_api(uploadSource, callback, options)).catch((error) => callback({ - error - })); + uploadSourcePromise.catch(() => {}); return; } - return uploadSourcePromise.then((uploadSource) => call_upload_api(uploadSource, callback, options)).catch((error) => { - callback({ - error - }); - throw error; - }); + return uploadSourcePromise; } if (isUploadData(file)) { diff --git a/test/unit/upload_blob_spec.js b/test/unit/upload_blob_spec.js new file mode 100644 index 00000000..ce8dfaaa --- /dev/null +++ b/test/unit/upload_blob_spec.js @@ -0,0 +1,58 @@ +const assert = require('assert'); +const sinon = require('sinon'); +const { Readable, Writable } = require('stream'); +const https = require('https'); + +const cloudinary = require('../../cloudinary'); +const createTestConfig = require('../testUtils/createTestConfig'); + +// Give each request a fake HTTP 500 response. No network call occurs. +function fakeErrorRequest(options, onResponse) { + const request = new Writable({ + write(chunk, encoding, done) { + done(); + } + }); + request.setTimeout = () => request; + request.abort = () => {}; + request.on('finish', () => { + const response = new Readable({ + read() {} + }); + response.statusCode = 500; + response.headers = {}; + onResponse(response); + response.push('{"error":{"message":"boom"}}'); + response.push(null); + }); + return request; +} + +describe('upload of a Blob', function () { + let requestStub; + + before(function () { + if (typeof Blob === 'undefined') { + this.skip(); + } + }); + + beforeEach(function () { + cloudinary.config(createTestConfig()); + requestStub = sinon.stub(https, 'request').callsFake(fakeErrorRequest); + }); + + afterEach(function () { + requestStub.restore(); + }); + + it('should call the callback one time when the upload fails', async function () { + const callback = sinon.spy(); + const blob = new Blob(['sample'], { type: 'text/plain' }); + + await assert.rejects(cloudinary.v2.uploader.upload(blob, callback), { message: 'boom' }); + + sinon.assert.calledOnce(callback); + sinon.assert.calledWith(callback, sinon.match({ message: 'boom', http_code: 500 })); + }); +}); From c4960261d6e03b8e2052eee723e2de76db9f6bc3 Mon Sep 17 00:00:00 2001 From: TalLevAmi Date: Tue, 6 Oct 2026 11:20:32 +0000 Subject: [PATCH 4/6] fix: read upload options at once and stream Blob uploads Apply the review findings on in-memory upload inputs: - Make the upload source and call call_api at once, so that a later change to options does not change the upload. Read the Blob body in post(), with the same error handling as the fs path. A read failure now rejects with { error }, and config errors are thrown at once for all input types. - Find a Blob by its shape, so that polyfill Blobs and Blobs from other realms also work. - Escape quotes, CR and LF in the multipart filename and encode the file header as UTF-8. - Add the filename option to UploadApiOptions and the README. - Remove the jsdom-only Blob fallback and the FileReader branch. Use the Node Blob in the integration spec. - Send a Uint8Array as a zero-copy view. Stream a Blob with Readable.fromWeb, and read it with arrayBuffer() if that fails. - Use a structural UploadBlob type in place of the global Blob. --- README.md | 9 + lib/uploader.js | 163 ++++++------------ .../integration/api/uploader/uploader_spec.js | 21 ++- test/unit/upload_blob_spec.js | 98 +++++++++++ types/cloudinary_ts_spec.ts | 6 + types/index.d.ts | 15 +- 6 files changed, 202 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 2a24ac73..42b3d7f1 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,15 @@ main().catch((error) => { Save as `quickstart.js` and run `node quickstart.js`. [Create a free account](https://cloudinary.com/users/register_free) if you don't have one — or run `npx @cloudinary/cloud` to [provision one without signing up](docs/get-credentials.md). +`upload()` also accepts a `Buffer`, `Uint8Array`, `ArrayBuffer`, `Blob` or `File`. Pass `filename` +so that raw uploads keep their extension and `original_filename` is useful (the default is the +`File` name, or `"file"`): + +```js +const csv = Buffer.from('id,name\n1,sample\n'); +await cloudinary.uploader.upload(csv, { resource_type: 'raw', filename: 'report.csv' }); +``` + ## Common tasks - [Get Cloudinary credentials](docs/get-credentials.md) diff --git a/lib/uploader.js b/lib/uploader.js index beba582d..0739c312 100644 --- a/lib/uploader.js +++ b/lib/uploader.js @@ -3,7 +3,7 @@ const { extname, basename } = require('path'); -const Writable = require("stream").Writable; +const { Readable, Writable } = require("stream"); // eslint-disable-next-line import/order const { upload_prefix } = require("./config")(); @@ -52,117 +52,56 @@ exports.unsigned_upload = function unsigned_upload(file, upload_preset, callback }; exports.upload = function upload(file, callback, options = {}) { - callback = typeof callback === "function" ? callback : function () { - }; - - if (isBlob(file)) { - // After call_api returns, call_api reports its own failures. Do not call the callback again. - let apiCalled = false; - let uploadSourcePromise = Promise.resolve().then(() => getBlobArrayBuffer(file)).then((arrayBuffer) => { - let result = call_upload_api(toUploadSource(Buffer.from(arrayBuffer), options, { - contentType: file.type, - filename: file.name - }), callback, options); - apiCalled = true; - return result; - }).catch((error) => { - if (!apiCalled) { - callback({ - error - }); - } - throw error; - }); - - if (options.disable_promises) { - uploadSourcePromise.catch(() => {}); - return; - } - - return uploadSourcePromise; - } - + // Read all options now. A later change to options must not change this upload. if (isUploadData(file)) { - file = toUploadSource(file, options); + file = { + data: file instanceof ArrayBuffer ? Buffer.from(file) : Buffer.from(file.buffer, file.byteOffset, file.byteLength), + filename: options.filename || "file", + contentType: "application/octet-stream" + }; + } else if (isBlob(file)) { + file = { + blob: file, + filename: options.filename || file.name || "file", + contentType: file.type || "application/octet-stream" + }; } - - return call_upload_api(file, callback, options); -}; - -function call_upload_api(file, callback, options) { return call_api("upload", callback, options, function () { let params = build_upload_params(options); return isRemoteUrl(file) ? [params, { file: file }] : [params, {}, file]; }); -} - -function isUint8Array(file) { - return typeof Uint8Array !== "undefined" && file instanceof Uint8Array; -} +}; -function isArrayBuffer(file) { - return typeof ArrayBuffer !== "undefined" && file instanceof ArrayBuffer; +function isUploadData(file) { + return file instanceof Uint8Array || file instanceof ArrayBuffer; } +// Find a Blob or File by its shape. Blobs from polyfills or other realms fail "instanceof Blob". function isBlob(file) { - return typeof Blob !== "undefined" && file instanceof Blob; + return isObject(file) && typeof file.arrayBuffer === "function" && typeof file.size === "number" && typeof file.type === "string"; } -function getBlobArrayBuffer(file) { - if (typeof file.arrayBuffer === "function") { - return file.arrayBuffer(); - } - - let blobBuffer = getBlobBuffer(file); - if (blobBuffer) { - return Promise.resolve(blobBuffer.buffer.slice(blobBuffer.byteOffset, blobBuffer.byteOffset + blobBuffer.byteLength)); - } - - if (typeof FileReader !== "undefined") { - return new Promise((resolve, reject) => { - let reader = new FileReader(); - reader.onload = function () { - resolve(reader.result); - }; - reader.onerror = function () { - reject(reader.error || new Error("Failed to read the Blob")); - }; - reader.readAsArrayBuffer(file); - }); - } - - return Promise.reject(new Error("Blob upload requires Blob.arrayBuffer() or FileReader support")); +function isUploadSource(file) { + return isObject(file) && (Buffer.isBuffer(file.data) || isBlob(file.blob)); } -function getBlobBuffer(file) { - if (typeof Object.getOwnPropertySymbols !== "function") { - return null; - } - - for (let symbol of Object.getOwnPropertySymbols(file)) { - let implementation = file[symbol]; - if (implementation && Buffer.isBuffer(implementation._buffer)) { - return implementation._buffer; +// Send the Blob as a stream. If the runtime cannot do this, read the full Blob into memory. +function pipeBlob(blob, upload_stream, onError) { + let source = null; + try { + if (typeof blob.stream === "function" && typeof Readable.fromWeb === "function") { + source = Readable.fromWeb(blob.stream()); } + } catch (error) { + source = null; } - - return null; -} - -function isUploadData(file) { - return Buffer.isBuffer(file) || isUint8Array(file) || isArrayBuffer(file); -} - -function isUploadSource(file) { - return isObject(file) && Buffer.isBuffer(file.data); -} - -function toUploadSource(file, options = {}, extra = {}) { - return { - data: Buffer.isBuffer(file) ? file : Buffer.from(file), - filename: options.filename || extra.filename || "file", - contentType: extra.contentType || "application/octet-stream" - }; + if (source) { + source.on('error', onError).pipe(upload_stream); + return; + } + Promise.resolve().then(() => blob.arrayBuffer()).then((arrayBuffer) => { + upload_stream.end(Buffer.from(arrayBuffer)); + }, onError); } exports.upload_large = function upload_large(path, callback, options = {}) { @@ -648,7 +587,7 @@ function post(url, post_data, boundary, file, callback, options) { let oauth_token = options.oauth_token || config().oauth_token; if ((file != null) || options.stream) { let { filename, contentType } = getFileUploadOptions(file, options); - file_header = Buffer.from(encodeFilePart(boundary, contentType, 'file', filename), 'binary'); + file_header = Buffer.from(encodeFilePart(boundary, contentType, 'file', filename), 'utf8'); } const parsedUrl = new URL(url); let post_options = { @@ -723,15 +662,18 @@ function post(url, post_data, boundary, file, callback, options) { } if (file != null) { post_request.write(file_header); - if (isUploadSource(file)) { - upload_stream.end(file.data); + let onReadError = function (error) { + callback({ + error: error + }); + return post_request.abort(); + }; + if (!isUploadSource(file)) { + fs.createReadStream(file).on('error', onReadError).pipe(upload_stream); + } else if (file.blob) { + pipeBlob(file.blob, upload_stream, onReadError); } else { - fs.createReadStream(file).on('error', function (error) { - callback({ - error: error - }); - return post_request.abort(); - }).pipe(upload_stream); + upload_stream.end(file.data); } } else { post_request.write(finish_buffer); @@ -750,8 +692,8 @@ function getFileUploadOptions(file, options = {}) { if (isUploadSource(file)) { return { - filename: file.filename || "file", - contentType: file.contentType || "application/octet-stream" + filename: file.filename, + contentType: file.contentType }; } @@ -771,10 +713,15 @@ function encodeFieldPart(boundary, name, value) { ].join(''); } +// Escape the filename as browsers do. A quote or a line break must not end the header. +function escapeFilename(filename) { + return String(filename).replace(/"/g, '%22').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); +} + function encodeFilePart(boundary, type, name, filename) { return [ `--${boundary}\r\n`, - `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n`, + `Content-Disposition: form-data; name="${name}"; filename="${escapeFilename(filename)}"\r\n`, `Content-Type: ${type}\r\n`, '\r\n', '' diff --git a/test/integration/api/uploader/uploader_spec.js b/test/integration/api/uploader/uploader_spec.js index a6d0571c..7cd51699 100644 --- a/test/integration/api/uploader/uploader_spec.js +++ b/test/integration/api/uploader/uploader_spec.js @@ -50,6 +50,7 @@ const SAMPLE_IMAGE_URL_1 = "https://res.cloudinary.com/demo/image/upload/sample" const SAMPLE_IMAGE_URL_2 = "https://res.cloudinary.com/demo/image/upload/car" const { URL: NodeURL, URLSearchParams: NodeURLSearchParams } = require('url'); +const { Blob: NodeBlob, File: NodeFile } = require('buffer'); let cleanupJsdom; describe("uploader", function () { @@ -132,7 +133,8 @@ describe("uploader", function () { it("should successfully upload a Blob", function () { const buffer = fs.readFileSync(IMAGE_FILE); - const blob = new Blob([buffer], { type: "image/png" }); + // jsdom-global replaces global.Blob. Use the Node Blob. + const blob = new NodeBlob([buffer], { type: "image/png" }); return cloudinary.v2.uploader.upload(blob, { tags: UPLOAD_TAGS }).then(function (result) { @@ -146,9 +148,26 @@ describe("uploader", function () { const buffer = fs.readFileSync(RAW_FILE); return cloudinary.v2.uploader.upload(buffer, { resource_type: "raw", + filename: "report.docx", + use_filename: true, tags: UPLOAD_TAGS }).then(function (result) { expect(result.resource_type).to.eql("raw"); + expect(result.original_filename).to.eql("report"); + expect(result.public_id).to.match(/^report_\w+\.docx$/); + }); + }); + + it("should keep a non-ASCII File name as original_filename", function () { + if (typeof NodeFile === "undefined") { + this.skip(); + } + const file = new NodeFile([fs.readFileSync(IMAGE_FILE)], "zdjęcie.png", { type: "image/png" }); + return cloudinary.v2.uploader.upload(file, { + use_filename: true, + tags: UPLOAD_TAGS + }).then(function (result) { + expect(result.original_filename).to.eql("zdjęcie"); }); }); diff --git a/test/unit/upload_blob_spec.js b/test/unit/upload_blob_spec.js index ce8dfaaa..7146b273 100644 --- a/test/unit/upload_blob_spec.js +++ b/test/unit/upload_blob_spec.js @@ -6,10 +6,19 @@ const https = require('https'); const cloudinary = require('../../cloudinary'); const createTestConfig = require('../testUtils/createTestConfig'); +// Keep the bytes of the last request body. +let sentChunks = []; + +function sentBody() { + return Buffer.concat(sentChunks); +} + // Give each request a fake HTTP 500 response. No network call occurs. function fakeErrorRequest(options, onResponse) { + sentChunks = []; const request = new Writable({ write(chunk, encoding, done) { + sentChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding)); done(); } }); @@ -55,4 +64,93 @@ describe('upload of a Blob', function () { sinon.assert.calledOnce(callback); sinon.assert.calledWith(callback, sinon.match({ message: 'boom', http_code: 500 })); }); + + it('should use the options that were set when upload() was called', async function () { + const options = { public_id: 'first' }; + const promise = cloudinary.v2.uploader.upload(new Blob(['sample']), options); + options.public_id = 'second'; + await promise.catch(() => {}); + + const body = sentBody().toString('utf8'); + assert.ok(body.includes('name="public_id"\r\n\r\nfirst\r\n')); + assert.ok(!body.includes('second')); + }); + + it('should upload a Blob-like object that has no stream()', async function () { + const blobLike = { + arrayBuffer: () => Promise.resolve(new Uint8Array(Buffer.from('shape-bytes')).buffer), + size: 11, + type: 'text/plain', + name: 'shape.txt' + }; + await cloudinary.v2.uploader.upload(blobLike).catch(() => {}); + + const body = sentBody().toString('utf8'); + assert.ok(body.includes('filename="shape.txt"\r\nContent-Type: text/plain\r\n\r\nshape-bytes\r\n--')); + }); + + it('should reject with { error } when the Blob cannot be read', async function () { + const callback = sinon.spy(); + const readError = new Error('read failed'); + const blobLike = { + arrayBuffer: () => Promise.reject(readError), + size: 1, + type: '' + }; + + await assert.rejects(cloudinary.v2.uploader.upload(blobLike, callback), (error) => error.error === readError); + sinon.assert.calledOnce(callback); + // The v2 callback gets the error itself, as for a file that cannot be read. + sinon.assert.calledWith(callback, readError); + }); + + it('should throw synchronously when cloud_name is missing', function () { + delete cloudinary.config().cloud_name; + try { + assert.throws(() => cloudinary.v2.uploader.upload(new Blob(['sample'])), /Must supply cloud_name/); + assert.throws(() => cloudinary.v2.uploader.upload(Buffer.from('sample')), /Must supply cloud_name/); + } finally { + cloudinary.config(true); + } + }); + + it('should escape the filename and send the header as UTF-8', async function () { + const names = { + 'a"b\r\nX-Injected: 1.png': 'a%22b%0D%0AX-Injected: 1.png', + 'zdjęcie.png': 'zdjęcie.png', + '日本.png': '日本.png' + }; + for (const [name, expected] of Object.entries(names)) { + // eslint-disable-next-line no-await-in-loop + await cloudinary.v2.uploader.upload(Buffer.from('x'), { filename: name }).catch(() => {}); + const body = sentBody(); + assert.ok(body.includes(Buffer.from(`filename="${expected}"\r\n`, 'utf8')), name); + assert.ok(!body.includes('\r\nX-Injected'), name); + } + }); + + it('should stream a native Blob and send all of its bytes', async function () { + if (typeof Blob.prototype.stream !== 'function' || typeof Readable.fromWeb !== 'function') { + this.skip(); + } + const bytes = require('crypto').randomBytes(256 * 1024); + const blob = new Blob([bytes], { type: 'application/pdf' }); + const arrayBufferSpy = sinon.spy(blob, 'arrayBuffer'); + await cloudinary.v2.uploader.upload(blob).catch(() => {}); + + sinon.assert.notCalled(arrayBufferSpy); + const body = sentBody(); + const header = Buffer.from('Content-Type: application/pdf\r\n\r\n'); + const start = body.indexOf(header) + header.length; + assert.ok(body.subarray(start, start + bytes.length).equals(bytes)); + assert.strictEqual(body.subarray(start + bytes.length, start + bytes.length + 4).toString(), '\r\n--'); + }); + + it('should send only the bytes of a Uint8Array view', async function () { + const source = Buffer.from('xxHELLOyy'); + const view = new Uint8Array(source.buffer, source.byteOffset + 2, 5); + await cloudinary.v2.uploader.upload(view).catch(() => {}); + + assert.ok(sentBody().toString('utf8').includes('\r\n\r\nHELLO\r\n--')); + }); }); diff --git a/types/cloudinary_ts_spec.ts b/types/cloudinary_ts_spec.ts index 194f6b7a..54d4abca 100644 --- a/types/cloudinary_ts_spec.ts +++ b/types/cloudinary_ts_spec.ts @@ -912,6 +912,12 @@ cloudinary.v2.uploader.upload(new ArrayBuffer(8)); // $ExpectType Promise cloudinary.v2.uploader.upload(new Blob(["sample"], {type: "text/plain"})); +// $ExpectType Promise +cloudinary.v2.uploader.upload({arrayBuffer: async () => new ArrayBuffer(8), type: "text/csv", size: 8, name: "report.csv"}); + +// $ExpectType Promise +cloudinary.v2.uploader.upload(Buffer.from("a,b"), {resource_type: "raw", filename: "report.csv"}); + // $ExpectType UploadStream | Promise cloudinary.v2.uploader.upload_large("my_large_video.mp4", { diff --git a/types/index.d.ts b/types/index.d.ts index eec01bc4..14ad6412 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -516,6 +516,11 @@ declare module 'cloudinary' { eval?: string; exif?: boolean; faces?: boolean; + /** + * The filename to send for a Buffer, Uint8Array, ArrayBuffer or Blob upload, for example 'report.csv'. + * The default is the File name, or "file". Raw assets get their extension from this name. + */ + filename?: string; filename_override?: string; folder?: string; format?: VideoFormat | ImageFormat; @@ -1285,7 +1290,15 @@ declare module 'cloudinary' { /****************************** Upload API V2 Methods *************************************/ namespace uploader { - type UploadFile = string | Buffer | Uint8Array | ArrayBuffer | Blob; + /** A Blob or File. The SDK finds it by its shape, so polyfill Blobs also work. */ + interface UploadBlob { + arrayBuffer(): Promise; + type: string; + size: number; + name?: string; + } + + type UploadFile = string | Buffer | Uint8Array | ArrayBuffer | UploadBlob; function add_context(context: string, public_ids: string[], options?: { type?: DeliveryType, From 0135a2498054cd49aeef33728d1e745cf5cb36f9 Mon Sep 17 00:00:00 2001 From: TalLevAmi Date: Tue, 6 Oct 2026 11:42:04 +0000 Subject: [PATCH 5/6] test: skip the Blob upload test when Node has no Blob Node versions before 15.7 (and 14.18) have no Blob in the buffer module. Skip the integration test on these versions, as the File test does. --- test/integration/api/uploader/uploader_spec.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/integration/api/uploader/uploader_spec.js b/test/integration/api/uploader/uploader_spec.js index 7cd51699..82e4154f 100644 --- a/test/integration/api/uploader/uploader_spec.js +++ b/test/integration/api/uploader/uploader_spec.js @@ -132,6 +132,9 @@ describe("uploader", function () { }); it("should successfully upload a Blob", function () { + if (typeof NodeBlob === "undefined") { + this.skip(); + } const buffer = fs.readFileSync(IMAGE_FILE); // jsdom-global replaces global.Blob. Use the Node Blob. const blob = new NodeBlob([buffer], { type: "image/png" }); From 6cc9b17590ef79c9125508d079f5f8f12c98fdc5 Mon Sep 17 00:00:00 2001 From: TalLevAmi Date: Tue, 6 Oct 2026 11:45:51 +0000 Subject: [PATCH 6/6] test: run the Blob-like unit tests on Node without Blob Skip only the tests that need a native Blob. On Node before 15.7, the tests with a Blob-like object now test the arrayBuffer() fallback. Do not use assert.rejects, because Node 9 does not have it. --- test/unit/upload_blob_spec.js | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/test/unit/upload_blob_spec.js b/test/unit/upload_blob_spec.js index 7146b273..d2417ff0 100644 --- a/test/unit/upload_blob_spec.js +++ b/test/unit/upload_blob_spec.js @@ -37,15 +37,16 @@ function fakeErrorRequest(options, onResponse) { return request; } +// Node before 15.7 has no global Blob. Run the Blob-like tests there to test the arrayBuffer() fallback. +function skipWithoutBlob(test) { + if (typeof Blob === 'undefined') { + test.skip(); + } +} + describe('upload of a Blob', function () { let requestStub; - before(function () { - if (typeof Blob === 'undefined') { - this.skip(); - } - }); - beforeEach(function () { cloudinary.config(createTestConfig()); requestStub = sinon.stub(https, 'request').callsFake(fakeErrorRequest); @@ -56,6 +57,7 @@ describe('upload of a Blob', function () { }); it('should call the callback one time when the upload fails', async function () { + skipWithoutBlob(this); const callback = sinon.spy(); const blob = new Blob(['sample'], { type: 'text/plain' }); @@ -66,6 +68,7 @@ describe('upload of a Blob', function () { }); it('should use the options that were set when upload() was called', async function () { + skipWithoutBlob(this); const options = { public_id: 'first' }; const promise = cloudinary.v2.uploader.upload(new Blob(['sample']), options); options.public_id = 'second'; @@ -98,16 +101,23 @@ describe('upload of a Blob', function () { type: '' }; - await assert.rejects(cloudinary.v2.uploader.upload(blobLike, callback), (error) => error.error === readError); + // Node 9 has no assert.rejects. + const error = await cloudinary.v2.uploader.upload(blobLike, callback).then(() => null, (e) => e); + assert.strictEqual(error && error.error, readError); sinon.assert.calledOnce(callback); // The v2 callback gets the error itself, as for a file that cannot be read. sinon.assert.calledWith(callback, readError); }); it('should throw synchronously when cloud_name is missing', function () { + const blobLike = { + arrayBuffer: () => Promise.resolve(new ArrayBuffer(1)), + size: 1, + type: '' + }; delete cloudinary.config().cloud_name; try { - assert.throws(() => cloudinary.v2.uploader.upload(new Blob(['sample'])), /Must supply cloud_name/); + assert.throws(() => cloudinary.v2.uploader.upload(blobLike), /Must supply cloud_name/); assert.throws(() => cloudinary.v2.uploader.upload(Buffer.from('sample')), /Must supply cloud_name/); } finally { cloudinary.config(true); @@ -130,7 +140,7 @@ describe('upload of a Blob', function () { }); it('should stream a native Blob and send all of its bytes', async function () { - if (typeof Blob.prototype.stream !== 'function' || typeof Readable.fromWeb !== 'function') { + if (typeof Blob === 'undefined' || typeof Blob.prototype.stream !== 'function' || typeof Readable.fromWeb !== 'function') { this.skip(); } const bytes = require('crypto').randomBytes(256 * 1024);