Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@directus/storage-driver-cloudinary

Package Overview
Dependencies
Maintainers
2
Versions
47
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@directus/storage-driver-cloudinary - npm Package Compare versions

Comparing version 10.0.11 to 10.0.12

11

dist/index.d.ts

@@ -1,5 +0,5 @@

/// <reference types="node" resolution-mode="require"/>
import type { Driver, Range } from '@directus/storage';
import { Driver, Range } from '@directus/storage';
import { Readable } from 'node:stream';
export type DriverCloudinaryConfig = {
type DriverCloudinaryConfig = {
root?: string;

@@ -11,3 +11,3 @@ cloudName: string;

};
export declare class DriverCloudinary implements Driver {
declare class DriverCloudinary implements Driver {
private root;

@@ -65,2 +65,3 @@ private apiKey;

}
export default DriverCloudinary;
export { DriverCloudinary, DriverCloudinaryConfig, DriverCloudinary as default };

@@ -1,324 +0,393 @@

import { normalizePath } from '@directus/utils';
import { Blob, Buffer } from 'node:buffer';
import { createHash } from 'node:crypto';
import { extname, join, parse } from 'node:path';
import { Readable } from 'node:stream';
import PQueue from 'p-queue';
import { FormData, fetch } from 'undici';
import { IMAGE_EXTENSIONS, VIDEO_EXTENSIONS } from './constants.js';
export class DriverCloudinary {
root;
apiKey;
apiSecret;
cloudName;
accessMode;
constructor(config) {
this.root = config.root ? normalizePath(config.root, { removeLeading: true }) : '';
this.apiKey = config.apiKey;
this.apiSecret = config.apiSecret;
this.cloudName = config.cloudName;
this.accessMode = config.accessMode;
// src/index.ts
import { normalizePath } from "@directus/utils";
import { Blob, Buffer } from "buffer";
import { createHash } from "crypto";
import { extname, join, parse } from "path";
import { Readable } from "stream";
import PQueue from "p-queue";
import { FormData, fetch } from "undici";
// src/constants.ts
var IMAGE_EXTENSIONS = [
".ai",
".avif",
".png",
".webp",
".bmp",
".bw",
".dfvu",
".ps",
".ept",
".eps",
".eps3",
".fbx",
".flif",
".gif",
".glb",
".gltf",
".heif",
".heic",
".ico",
".indd",
".jpg",
".jpe",
".jpeg",
".jp2",
".wdp",
".jxr",
".hdp",
".obj",
".pdf",
".ply",
".png",
".psd",
".arw",
".cr2",
".svg",
".tga",
".tif",
".tiff",
".u3ma",
".usdz",
".webp"
];
var VIDEO_EXTENSIONS = [
".3g2",
".3gp",
".avi",
".flv",
".m3u8",
".ts",
".m2ts",
".mts",
".mov",
".mkv",
".mp4",
".mpeg",
".mpd",
".mxf",
".ogv",
".webm",
".wmv"
];
// src/index.ts
var DriverCloudinary = class {
root;
apiKey;
apiSecret;
cloudName;
accessMode;
constructor(config) {
this.root = config.root ? normalizePath(config.root, { removeLeading: true }) : "";
this.apiKey = config.apiKey;
this.apiSecret = config.apiSecret;
this.cloudName = config.cloudName;
this.accessMode = config.accessMode;
}
fullPath(filepath) {
return normalizePath(join(this.root, filepath), { removeLeading: true });
}
toFormUrlEncoded(obj, options) {
let entries = Object.entries(obj);
if (options?.sort) {
entries = entries.sort(([keyA], [keyB]) => keyA.localeCompare(keyB));
}
fullPath(filepath) {
return normalizePath(join(this.root, filepath), { removeLeading: true });
return decodeURIComponent(new URLSearchParams(entries).toString());
}
/**
* Generate the Cloudinary sha256 signature for the given payload
* @see https://cloudinary.com/documentation/signatures
*/
getFullSignature(payload) {
const denylist = ["file", "cloud_name", "resource_type", "api_key"];
const signaturePayload = Object.fromEntries(
Object.entries(payload).filter(([key]) => denylist.includes(key) === false)
);
const signaturePayloadString = this.toFormUrlEncoded(signaturePayload, { sort: true });
return createHash("sha256").update(signaturePayloadString + this.apiSecret).digest("hex");
}
/**
* Creates inline URL signature for use with the image reading API
* @see https://cloudinary.com/documentation/advanced_url_delivery_options#generating_delivery_url_signatures
*/
getParameterSignature(filepath) {
return `s--${createHash("sha256").update(filepath + this.apiSecret).digest("base64url").substring(0, 8)}--`;
}
getTimestamp() {
return String((/* @__PURE__ */ new Date()).getTime());
}
/**
* Used to guess what resource type is appropriate for a given filepath
* @see https://cloudinary.com/documentation/image_transformations#image_upload_note
*/
getResourceType(filepath) {
const fileExtension = extname(filepath);
if (IMAGE_EXTENSIONS.includes(fileExtension))
return "image";
if (VIDEO_EXTENSIONS.includes(fileExtension))
return "video";
return "raw";
}
/**
* For Cloudinary Admin APIs, the file extension needs to be omitted for images and videos. Raw
* on the other hand requires the extension to be present.
*/
getPublicId(filepath) {
const { base, name } = parse(filepath);
const resourceType = this.getResourceType(filepath);
if (resourceType === "raw")
return base;
return name;
}
/**
* Cloudinary sometimes treats the folder path leading up to the file ID as a separate request
* entity. This method will return the folder path without the filename for those uses. The
* leading slash is removed.
*/
getFolderPath(filepath) {
return parse(filepath).dir;
}
/**
* Generates the Authorization header value for Cloudinary's basic auth endpoints
*/
getBasicAuth() {
const credentials = `${this.apiKey}:${this.apiSecret}`;
const base64 = Buffer.from(credentials).toString("base64");
return `Basic ${base64}`;
}
async read(filepath, range) {
const resourceType = this.getResourceType(filepath);
const fullPath = this.fullPath(filepath);
const signature = this.getParameterSignature(fullPath);
const url = `https://res.cloudinary.com/${this.cloudName}/${resourceType}/upload/${signature}/${fullPath}`;
const requestInit = { method: "GET" };
if (range) {
requestInit.headers = {
Range: `bytes=${range.start ?? ""}-${range.end ?? ""}`
};
}
toFormUrlEncoded(obj, options) {
let entries = Object.entries(obj);
if (options?.sort) {
entries = entries.sort(([keyA], [keyB]) => keyA.localeCompare(keyB));
}
return decodeURIComponent(new URLSearchParams(entries).toString());
const response = await fetch(url, requestInit);
if (response.status >= 400 || !response.body) {
throw new Error(`No stream returned for file "${filepath}"`);
}
/**
* Generate the Cloudinary sha256 signature for the given payload
* @see https://cloudinary.com/documentation/signatures
*/
getFullSignature(payload) {
const denylist = ['file', 'cloud_name', 'resource_type', 'api_key'];
const signaturePayload = Object.fromEntries(Object.entries(payload).filter(([key]) => denylist.includes(key) === false));
const signaturePayloadString = this.toFormUrlEncoded(signaturePayload, { sort: true });
return createHash('sha256')
.update(signaturePayloadString + this.apiSecret)
.digest('hex');
return Readable.fromWeb(response.body);
}
async stat(filepath) {
const fullPath = this.fullPath(filepath);
const resourceType = this.getResourceType(fullPath);
const publicId = this.getPublicId(fullPath);
const folder = this.getFolderPath(fullPath);
const parameters = {
public_id: join(folder, publicId),
type: "upload",
api_key: this.apiKey,
timestamp: this.getTimestamp()
};
const signature = this.getFullSignature(parameters);
const body = this.toFormUrlEncoded({
signature,
...parameters
});
const url = `https://api.cloudinary.com/v1_1/${this.cloudName}/${resourceType}/explicit`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
},
body
});
if (response.status >= 400) {
throw new Error(`No stat returned for file "${filepath}"`);
}
/**
* Creates inline URL signature for use with the image reading API
* @see https://cloudinary.com/documentation/advanced_url_delivery_options#generating_delivery_url_signatures
*/
getParameterSignature(filepath) {
return `s--${createHash('sha256')
.update(filepath + this.apiSecret)
.digest('base64url')
.substring(0, 8)}--`;
const { bytes, created_at } = await response.json();
return { size: bytes, modified: new Date(created_at) };
}
async exists(filepath) {
try {
await this.stat(filepath);
return true;
} catch {
return false;
}
getTimestamp() {
return String(new Date().getTime());
}
async move(src, dest) {
const fullSrc = this.fullPath(src);
const fullDest = this.fullPath(dest);
const resourceType = this.getResourceType(fullSrc);
const url = `https://api.cloudinary.com/v1_1/${this.cloudName}/${resourceType}/rename`;
const parameters = {
from_public_id: this.getPublicId(fullSrc),
to_public_id: this.getPublicId(fullDest),
api_key: this.apiKey,
timestamp: this.getTimestamp()
};
const signature = this.getFullSignature(parameters);
const body = this.toFormUrlEncoded({
...parameters,
signature
});
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
},
body
});
if (response.status >= 400) {
const responseData = await response.json();
throw new Error(`Can't move file "${src}": ${responseData?.error?.message ?? "Unknown"}`);
}
/**
* Used to guess what resource type is appropriate for a given filepath
* @see https://cloudinary.com/documentation/image_transformations#image_upload_note
*/
getResourceType(filepath) {
const fileExtension = extname(filepath);
if (IMAGE_EXTENSIONS.includes(fileExtension))
return 'image';
if (VIDEO_EXTENSIONS.includes(fileExtension))
return 'video';
return 'raw';
}
/**
* For Cloudinary Admin APIs, the file extension needs to be omitted for images and videos. Raw
* on the other hand requires the extension to be present.
*/
getPublicId(filepath) {
const { base, name } = parse(filepath);
const resourceType = this.getResourceType(filepath);
if (resourceType === 'raw')
return base;
return name;
}
/**
* Cloudinary sometimes treats the folder path leading up to the file ID as a separate request
* entity. This method will return the folder path without the filename for those uses. The
* leading slash is removed.
*/
getFolderPath(filepath) {
return parse(filepath).dir;
}
/**
* Generates the Authorization header value for Cloudinary's basic auth endpoints
*/
getBasicAuth() {
const credentials = `${this.apiKey}:${this.apiSecret}`;
const base64 = Buffer.from(credentials).toString('base64');
return `Basic ${base64}`;
}
async read(filepath, range) {
const resourceType = this.getResourceType(filepath);
const fullPath = this.fullPath(filepath);
const signature = this.getParameterSignature(fullPath);
const url = `https://res.cloudinary.com/${this.cloudName}/${resourceType}/upload/${signature}/${fullPath}`;
const requestInit = { method: 'GET' };
if (range) {
requestInit.headers = {
Range: `bytes=${range.start ?? ''}-${range.end ?? ''}`,
};
}
const response = await fetch(url, requestInit);
if (response.status >= 400 || !response.body) {
throw new Error(`No stream returned for file "${filepath}"`);
}
return Readable.fromWeb(response.body);
}
async stat(filepath) {
const fullPath = this.fullPath(filepath);
const resourceType = this.getResourceType(fullPath);
const publicId = this.getPublicId(fullPath);
const folder = this.getFolderPath(fullPath);
const parameters = {
public_id: join(folder, publicId),
type: 'upload',
api_key: this.apiKey,
timestamp: this.getTimestamp(),
}
async copy(src, dest) {
const stream = await this.read(src);
await this.write(dest, stream);
}
async write(filepath, content) {
const fullPath = this.fullPath(filepath);
const resourceType = this.getResourceType(fullPath);
const folderPath = this.getFolderPath(fullPath);
const uploadParameters = {
timestamp: this.getTimestamp(),
api_key: this.apiKey,
type: "upload",
access_mode: this.accessMode,
public_id: this.getPublicId(fullPath),
...folderPath ? {
asset_folder: folderPath,
use_asset_folder_as_public_id_prefix: "true"
} : {}
};
const signature = this.getFullSignature(uploadParameters);
let currentChunkSize = 0;
let totalSize = 0;
let uploaded = 0;
let error = null;
const queue = new PQueue({ concurrency: 10 });
const chunks = [];
for await (const chunk of content) {
chunks.push(chunk);
currentChunkSize += chunk.length;
if (currentChunkSize >= 55e5) {
const uploadChunkParams = {
resourceType,
blob: new Blob(chunks),
bytesOffset: uploaded,
bytesTotal: -1,
parameters: {
signature,
...uploadParameters
}
};
const signature = this.getFullSignature(parameters);
const body = this.toFormUrlEncoded({
signature,
...parameters,
queue.add(() => this.uploadChunk(uploadChunkParams)).catch((err) => {
error = err;
});
const url = `https://api.cloudinary.com/v1_1/${this.cloudName}/${resourceType}/explicit`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
body,
});
if (response.status >= 400) {
throw new Error(`No stat returned for file "${filepath}"`);
}
const { bytes, created_at } = (await response.json());
return { size: bytes, modified: new Date(created_at) };
uploaded += currentChunkSize;
currentChunkSize = 0;
chunks.length = 0;
}
totalSize += chunk.length;
}
async exists(filepath) {
try {
await this.stat(filepath);
return true;
queue.add(
() => this.uploadChunk({
resourceType,
blob: new Blob(chunks),
bytesOffset: uploaded,
bytesTotal: totalSize,
parameters: {
signature,
...uploadParameters
}
catch {
return false;
}
})
).catch((err) => {
error = err;
});
await queue.onIdle();
if (error) {
throw new Error(`Can't upload file "${filepath}": ${error.message}`, { cause: error });
}
async move(src, dest) {
const fullSrc = this.fullPath(src);
const fullDest = this.fullPath(dest);
const resourceType = this.getResourceType(fullSrc);
const url = `https://api.cloudinary.com/v1_1/${this.cloudName}/${resourceType}/rename`;
const parameters = {
from_public_id: this.getPublicId(fullSrc),
to_public_id: this.getPublicId(fullDest),
api_key: this.apiKey,
timestamp: this.getTimestamp(),
};
const signature = this.getFullSignature(parameters);
const body = this.toFormUrlEncoded({
...parameters,
signature,
});
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
body,
});
if (response.status >= 400) {
const responseData = (await response.json());
throw new Error(`Can't move file "${src}": ${responseData?.error?.message ?? 'Unknown'}`);
}
}
async uploadChunk({
resourceType,
blob,
bytesOffset,
bytesTotal,
parameters
}) {
const formData = new FormData();
formData.set("file", blob);
for (const [key, value] of Object.entries(parameters)) {
formData.set(key, value);
}
async copy(src, dest) {
const stream = await this.read(src);
await this.write(dest, stream);
const response = await fetch(`https://api.cloudinary.com/v1_1/${this.cloudName}/${resourceType}/upload`, {
method: "POST",
body: formData,
headers: {
/** @see https://support.cloudinary.com/hc/en-us/articles/208263735-Guidelines-for-self-implementing-chunked-upload-to-Cloudinary */
"X-Unique-Upload-Id": parameters.timestamp,
"Content-Range": `bytes ${bytesOffset}-${bytesOffset + blob.size - 1}/${bytesTotal}`
}
});
if (response.status >= 400) {
const responseData = await response.json();
throw new Error(responseData?.error?.message ?? "Unknown");
}
async write(filepath, content) {
const fullPath = this.fullPath(filepath);
const resourceType = this.getResourceType(fullPath);
const folderPath = this.getFolderPath(fullPath);
const uploadParameters = {
timestamp: this.getTimestamp(),
api_key: this.apiKey,
type: 'upload',
access_mode: this.accessMode,
public_id: this.getPublicId(fullPath),
...(folderPath
? {
asset_folder: folderPath,
use_asset_folder_as_public_id_prefix: 'true',
}
: {}),
};
const signature = this.getFullSignature(uploadParameters);
let currentChunkSize = 0;
let totalSize = 0;
let uploaded = 0;
let error = null;
const queue = new PQueue({ concurrency: 10 });
const chunks = [];
for await (const chunk of content) {
chunks.push(chunk);
currentChunkSize += chunk.length;
// Cloudinary requires each chunk to be at least 5MB. We'll submit the chunk as soon as we
// reach 5.5MB to be safe
if (currentChunkSize >= 5.5e6) {
const uploadChunkParams = {
resourceType,
blob: new Blob(chunks),
bytesOffset: uploaded,
bytesTotal: -1,
parameters: {
signature,
...uploadParameters,
},
};
queue
.add(() => this.uploadChunk(uploadChunkParams))
.catch((err) => {
error = err;
});
uploaded += currentChunkSize;
currentChunkSize = 0;
chunks.length = 0; // empty the array of chunks
}
totalSize += chunk.length;
}
async delete(filepath) {
const fullPath = this.fullPath(filepath);
const resourceType = this.getResourceType(fullPath);
const publicId = this.getPublicId(fullPath);
const folderPath = this.getFolderPath(fullPath);
const url = `https://api.cloudinary.com/v1_1/${this.cloudName}/${resourceType}/destroy`;
const parameters = {
timestamp: this.getTimestamp(),
api_key: this.apiKey,
resource_type: resourceType,
public_id: join(folderPath, publicId)
};
const signature = this.getFullSignature(parameters);
await fetch(url, {
method: "POST",
body: this.toFormUrlEncoded({
...parameters,
signature
}),
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
}
});
}
async *list(prefix = "") {
const fullPath = this.fullPath(prefix);
let nextCursor = "";
do {
const response = await fetch(
`https://api.cloudinary.com/v1_1/${this.cloudName}/resources/search?expression=${fullPath}*&next_cursor=${nextCursor}`,
{
method: "GET",
headers: {
Authorization: this.getBasicAuth()
}
}
queue
.add(() => this.uploadChunk({
resourceType,
blob: new Blob(chunks),
bytesOffset: uploaded,
bytesTotal: totalSize,
parameters: {
signature,
...uploadParameters,
},
}))
.catch((err) => {
error = err;
});
await queue.onIdle();
if (error) {
throw new Error(`Can't upload file "${filepath}": ${error.message}`, { cause: error });
}
}
async uploadChunk({ resourceType, blob, bytesOffset, bytesTotal, parameters, }) {
const formData = new FormData();
formData.set('file', blob);
for (const [key, value] of Object.entries(parameters)) {
formData.set(key, value);
}
const response = await fetch(`https://api.cloudinary.com/v1_1/${this.cloudName}/${resourceType}/upload`, {
method: 'POST',
body: formData,
headers: {
/** @see https://support.cloudinary.com/hc/en-us/articles/208263735-Guidelines-for-self-implementing-chunked-upload-to-Cloudinary */
'X-Unique-Upload-Id': parameters.timestamp,
'Content-Range': `bytes ${bytesOffset}-${bytesOffset + blob.size - 1}/${bytesTotal}`,
},
});
if (response.status >= 400) {
const responseData = (await response.json());
throw new Error(responseData?.error?.message ?? 'Unknown');
}
}
async delete(filepath) {
const fullPath = this.fullPath(filepath);
const resourceType = this.getResourceType(fullPath);
const publicId = this.getPublicId(fullPath);
const folderPath = this.getFolderPath(fullPath);
const url = `https://api.cloudinary.com/v1_1/${this.cloudName}/${resourceType}/destroy`;
const parameters = {
timestamp: this.getTimestamp(),
api_key: this.apiKey,
resource_type: resourceType,
public_id: join(folderPath, publicId),
};
const signature = this.getFullSignature(parameters);
await fetch(url, {
method: 'POST',
body: this.toFormUrlEncoded({
...parameters,
signature,
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
});
}
async *list(prefix = '') {
const fullPath = this.fullPath(prefix);
let nextCursor = '';
do {
const response = await fetch(`https://api.cloudinary.com/v1_1/${this.cloudName}/resources/search?expression=${fullPath}*&next_cursor=${nextCursor}`, {
method: 'GET',
headers: {
Authorization: this.getBasicAuth(),
},
});
const json = (await response.json());
if (response.status >= 400) {
const responseData = (await response.json());
throw new Error(`Can't list for prefix "${prefix}": ${responseData?.error?.message ?? 'Unknown'}`);
}
nextCursor = json.next_cursor;
for (const file of json.resources) {
const filename = file.public_id.substring(this.root.length);
if (file.resource_type === 'image' || file.resource_type === 'video')
yield `${filename}.${file.format}`;
else
yield filename;
}
} while (nextCursor);
}
}
export default DriverCloudinary;
);
const json = await response.json();
if (response.status >= 400) {
const responseData = await response.json();
throw new Error(`Can't list for prefix "${prefix}": ${responseData?.error?.message ?? "Unknown"}`);
}
nextCursor = json.next_cursor;
for (const file of json.resources) {
const filename = file.public_id.substring(this.root.length);
if (file.resource_type === "image" || file.resource_type === "video")
yield `${filename}.${file.format}`;
else
yield filename;
}
} while (nextCursor);
}
};
var src_default = DriverCloudinary;
export {
DriverCloudinary,
src_default as default
};
{
"name": "@directus/storage-driver-cloudinary",
"version": "10.0.11",
"version": "10.0.12",
"description": "Cloudinary file storage abstraction for `@directus/storage`",

@@ -26,4 +26,4 @@ "homepage": "https://directus.io",

"undici": "5.22.1",
"@directus/storage": "10.0.5",
"@directus/utils": "10.0.11"
"@directus/utils": "11.0.0",
"@directus/storage": "10.0.6"
},

@@ -33,11 +33,12 @@ "devDependencies": {

"@vitest/coverage-c8": "0.31.1",
"typescript": "5.0.4",
"tsup": "7.2.0",
"typescript": "5.2.2",
"vitest": "0.31.1",
"@directus/tsconfig": "1.0.0"
"@directus/tsconfig": "1.0.1"
},
"scripts": {
"build": "tsc --project tsconfig.prod.json",
"dev": "tsc --watch",
"build": "tsup src/index.ts --format=esm --dts",
"dev": "tsup src/index.ts --format=esm --dts --watch",
"test": "vitest --watch=false"
}
}
SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc