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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
92 changes: 85 additions & 7 deletions lib/uploader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")();
Expand Down Expand Up @@ -52,12 +52,58 @@ exports.unsigned_upload = function unsigned_upload(file, upload_preset, callback
};

exports.upload = function upload(file, callback, options = {}) {
// Read all options now. A later change to options must not change this upload.
if (isUploadData(file)) {
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_api("upload", callback, options, function () {
let params = build_upload_params(options);
return isRemoteUrl(file) ? [params, { file: file }] : [params, {}, file];
});
};

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 isObject(file) && typeof file.arrayBuffer === "function" && typeof file.size === "number" && typeof file.type === "string";
}

function isUploadSource(file) {
return isObject(file) && (Buffer.isBuffer(file.data) || isBlob(file.blob));
}

// 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;
}
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 = {}) {
if ((path != null) && isRemoteUrl(path)) {
// upload a remote file
Expand Down Expand Up @@ -540,9 +586,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), 'utf8');
}
const parsedUrl = new URL(url);
let post_options = {
Expand Down Expand Up @@ -617,19 +662,47 @@ function post(url, post_data, boundary, file, callback, options) {
}
if (file != null) {
post_request.write(file_header);
fs.createReadStream(file).on('error', function (error) {
let onReadError = function (error) {
callback({
error: error
});
return post_request.abort();
}).pipe(upload_stream);
};
if (!isUploadSource(file)) {
fs.createReadStream(file).on('error', onReadError).pipe(upload_stream);
} else if (file.blob) {
pipeBlob(file.blob, upload_stream, onReadError);
} else {
upload_stream.end(file.data);
}
} else {
post_request.write(finish_buffer);
post_request.end();
}
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,
contentType: file.contentType
};
}

return {
filename: basename(file),
contentType: "application/octet-stream"
};
}

function encodeFieldPart(boundary, name, value) {
return [
`--${boundary}\r\n`,
Expand All @@ -640,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',
''
Expand Down
97 changes: 97 additions & 0 deletions test/integration/api/uploader/uploader_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -95,6 +96,102 @@ 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 () {
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" });
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",
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");
});
});

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);
Expand Down
Loading
Loading