keystone
Version:
Web Application Framework and Admin GUI / Content Management System built on Express.js and Mongoose
596 lines (540 loc) • 17.8 kB
JavaScript
var _ = require('lodash');
var assign = require('object-assign');
var ensureCallback = require('keystone-storage-namefunctions/ensureCallback');
var FieldType = require('../Type');
var keystone = require('../../../');
var nameFunctions = require('keystone-storage-namefunctions');
var prototypeMethods = require('keystone-storage-namefunctions/prototypeMethods');
var sanitize = require('sanitize-filename');
var util = require('util');
var utils = require('keystone-utils');
/*
var CLOUDINARY_FIELDS = ['public_id', 'version', 'signature', 'format', 'resource_type', 'url', 'width', 'height', 'secure_url'];
*/
var DEFAULT_OPTIONS = {
// This makes Cloudinary assign a unique public_id and is the same as
// the legacy implementation
generateFilename: () => undefined,
whenExists: 'overwrite',
retryAttempts: 3, // For whenExists: 'retry'.
};
function getEmptyValue () {
return {
public_id: '',
version: 0,
signature: '',
format: '',
resource_type: '',
url: '',
width: 0,
height: 0,
secure_url: '',
};
}
/**
* CloudinaryImage FieldType Constructor
* @extends Field
* @api public
*/
function cloudinaryimage (list, path, options) {
this._underscoreMethods = ['format'];
this._fixedSize = 'full';
this._properties = ['select', 'selectPrefix', 'autoCleanup'];
if (options.filenameAsPublicID) {
// Produces the same result as the legacy filenameAsPublicID option
options.generateFilename = nameFunctions.originalFilename;
options.whenExists = 'overwrite';
}
options = assign({}, DEFAULT_OPTIONS, options);
options.generateFilename = ensureCallback(options.generateFilename);
cloudinaryimage.super_.call(this, list, path, options);
// validate cloudinary config
if (!keystone.get('cloudinary config')) {
throw new Error(
'Invalid Configuration\n\n'
+ 'CloudinaryImage fields (' + list.key + '.' + this.path + ') require the "cloudinary config" option to be set.\n\n'
+ 'See http://keystonejs.com/docs/configuration/#services-cloudinary for more information.\n'
);
}
}
cloudinaryimage.properName = 'CloudinaryImage';
util.inherits(cloudinaryimage, FieldType);
/**
* Gets the folder for images in this field
*/
cloudinaryimage.prototype.getFolder = function () {
var folder = null;
if (keystone.get('cloudinary folders') || this.options.folder) {
if (typeof this.options.folder === 'string') {
folder = this.options.folder;
} else {
var folderList = keystone.get('cloudinary prefix') ? [keystone.get('cloudinary prefix')] : [];
folderList.push(this.list.path);
folderList.push(this.path);
folder = folderList.join('/');
}
}
return folder;
};
/**
* Registers the field on the List's Mongoose Schema.
*/
cloudinaryimage.prototype.addToSchema = function (schema) {
var cloudinary = require('cloudinary');
var field = this;
var paths = this.paths = {
// cloudinary fields
public_id: this.path + '.public_id',
version: this.path + '.version',
signature: this.path + '.signature',
format: this.path + '.format',
resource_type: this.path + '.resource_type',
url: this.path + '.url',
width: this.path + '.width',
height: this.path + '.height',
secure_url: this.path + '.secure_url',
// virtuals
exists: this.path + '.exists',
folder: this.path + '.folder',
// form paths
select: this.path + '_select',
};
var schemaPaths = this._path.addTo({}, {
public_id: String,
version: Number,
signature: String,
format: String,
resource_type: String,
url: String,
width: Number,
height: Number,
secure_url: String,
});
schema.add(schemaPaths);
var exists = function (item) {
return (item.get(paths.public_id) ? true : false);
};
// The .exists virtual indicates whether an image is stored
schema.virtual(paths.exists).get(function () {
return schemaMethods.exists.apply(this);
});
// The .folder virtual returns the cloudinary folder used to upload/select images
schema.virtual(paths.folder).get(function () {
return schemaMethods.folder.apply(this);
});
var src = function (item, options) {
if (!exists(item)) {
return '';
}
options = (typeof options === 'object') ? options : {};
if (!('fetch_format' in options) && keystone.get('cloudinary webp') !== false) {
options.fetch_format = 'auto';
}
if (!('progressive' in options) && keystone.get('cloudinary progressive') !== false) {
options.progressive = true;
}
if (!('secure' in options) && keystone.get('cloudinary secure')) {
options.secure = true;
}
options.version = item.get(paths.version);
options.format = options.format || item.get(paths.format);
return cloudinary.url(item.get(paths.public_id), options);
};
var reset = function (item) {
item.set(field.path, getEmptyValue());
};
var addSize = function (options, width, height, other) {
if (width) options.width = width;
if (height) options.height = height;
if (typeof other === 'object') {
assign(options, other);
}
return options;
};
var schemaMethods = {
exists: function () {
return exists(this);
},
folder: function () {
return field.getFolder();
},
src: function (options) {
return src(this, options);
},
tag: function (options) {
return exists(this) ? cloudinary.image(this.get(field.path).public_id, options) : '';
},
scale: function (width, height, options) {
return src(this, addSize({ crop: 'scale' }, width, height, options));
},
fill: function (width, height, options) {
return src(this, addSize({ crop: 'fill', gravity: 'faces' }, width, height, options));
},
lfill: function (width, height, options) {
return src(this, addSize({ crop: 'lfill', gravity: 'faces' }, width, height, options));
},
fit: function (width, height, options) {
return src(this, addSize({ crop: 'fit' }, width, height, options));
},
limit: function (width, height, options) {
return src(this, addSize({ crop: 'limit' }, width, height, options));
},
pad: function (width, height, options) {
return src(this, addSize({ crop: 'pad' }, width, height, options));
},
lpad: function (width, height, options) {
return src(this, addSize({ crop: 'lpad' }, width, height, options));
},
crop: function (width, height, options) {
return src(this, addSize({ crop: 'crop', gravity: 'faces' }, width, height, options));
},
thumbnail: function (width, height, options) {
return src(this, addSize({ crop: 'thumb', gravity: 'faces' }, width, height, options));
},
/**
* Resets the value of the field
*
* @api public
*/
reset: function () {
reset(this);
},
/**
* Deletes the image from Cloudinary and resets the field
*
* @api public
*/
delete: function () {
var _this = this;
var promise = new Promise(function (resolve) {
cloudinary.uploader.destroy(_this.get(paths.public_id), function (result) {
resolve(result);
});
});
reset(this);
return promise;
},
/**
* Uploads the image to Cloudinary
*
* @api public
*/
upload: function (file, options) {
var promise = new Promise(function (resolve) {
cloudinary.uploader.upload(file, function (result) {
resolve(result);
}, options);
});
return promise;
},
};
_.forEach(schemaMethods, function (fn, key) {
field.underscoreMethod(key, fn);
});
// expose a method on the field to call schema methods
this.apply = function (item, method) {
return schemaMethods[method].apply(item, Array.prototype.slice.call(arguments, 2));
};
this.bindUnderscoreMethods();
};
/**
* Formats the field value
*/
cloudinaryimage.prototype.format = function (item) {
return item.get(this.paths.url);
};
/**
* Gets the field's data from an Item, as used by the React components
*/
cloudinaryimage.prototype.getData = function (item) {
var value = item.get(this.path);
return typeof value === 'object' ? value : {};
};
cloudinaryimage.prototype._originalGetOptions = cloudinaryimage.prototype.getOptions;
cloudinaryimage.prototype.getOptions = function () {
this._originalGetOptions();
// We are performing the check here, so that if cloudinary secure is added
// to keystone after the model is registered, it will still be respected.
// Setting secure overrides default `cloudinary secure`
if ('secure' in this.options) {
this.__options.secure = this.options.secure;
} else if (keystone.get('cloudinary secure')) {
this.__options.secure = keystone.get('cloudinary secure');
}
return this.__options;
};
/**
* Detects whether the field has been modified
*/
cloudinaryimage.prototype.isModified = function (item) {
return item.isModified(this.paths.public_id);
};
function validateInput (value) {
// undefined values are always valid
if (value === undefined || value === null || value === '') return true;
// If a string is provided, check it is an upload or delete instruction
// TODO: This should really validate files as well, but that's not pased to this method
if (typeof value === 'string' && /^(upload\:)|(delete$)|(data:[a-z\/]+;base64)|(https?\:\/\/)/.test(value)) return true;
// If the value is an object and has a cloudinary public_id, it is valid
if (typeof value === 'object' && value.public_id) return true;
// None of the above? we can't recognise it.
return false;
}
/**
* Validates that a value for this field has been provided in a data object
*/
cloudinaryimage.prototype.validateInput = function (data, callback) {
var value = this.getValueFromData(data);
var result = validateInput(value);
utils.defer(callback, result);
};
/**
* Validates that input has been provided
*/
cloudinaryimage.prototype.validateRequiredInput = function (item, data, callback) {
// TODO: We need to also get the `files` argument, so we can check for
// uploaded files. without it, this will return false negatives so we
// can't actually validate required input at the moment.
var result = true;
// var value = this.getValueFromData(data);
// var result = (value || item.get(this.path).public_id) ? true : false;
utils.defer(callback, result);
};
/**
* Always assumes the input is valid
*
* Deprecated
*/
cloudinaryimage.prototype.inputIsValid = function () {
return true;
};
/**
* Trim supported file extensions from the public id because cloudinary uses these at
* the end of the a url to dynamically convert the image filetype
*/
function trimSupportedFileExtensions (publicId) {
var supportedExtensions = [
'.jpg', '.jpe', '.jpeg', '.jpc', '.jp2', '.j2k', '.wdp', '.jxr',
'.hdp', '.png', '.gif', '.webp', '.bmp', '.tif', '.tiff', '.ico',
'.pdf', '.ps', '.ept', '.eps', '.eps3', '.psd', '.svg', '.ai',
'.djvu', '.flif', '.tga',
];
for (var i = 0; i < supportedExtensions.length; i++) {
var extension = supportedExtensions[i];
if (_.endsWith(publicId, extension)) {
return publicId.slice(0, -extension.length);
}
}
return publicId;
}
/**
* Updates the value for this field in the item from a data object
* TODO: It is not possible to remove an existing value and upload a new image
* in the same action, this should be supported
*/
cloudinaryimage.prototype.updateItem = function (item, data, files, callback) {
// Process arguments
if (typeof files === 'function') {
callback = files;
files = {};
}
if (!files) {
files = {};
}
var cloudinary = require('cloudinary');
var field = this;
// Prepare values
var value = this.getValueFromData(data);
var uploadedFile;
// Providing the string "remove" or "delete" removes the file and resets the field
if (value === 'remove' || value === 'delete') {
cloudinary.uploader.destroy(item.get(field.paths.public_id), function (result) {
if (result.error) {
callback(result.error);
} else {
item.set(field.path, getEmptyValue());
callback();
}
});
return;
}
// Find an uploaded file in the files argument, either referenced in the
// data argument or named with the field path / field_upload path + suffix
// Base64 data and remote URLs are also accepted as images to upload
if (typeof value === 'string' && value.substr(0, 7) === 'upload:') {
uploadedFile = files[value.substr(7)];
} else if (typeof value === 'string' && /^(data:[a-z\/]+;base64)|(https?\:\/\/)/.test(value)) {
uploadedFile = { path: value };
} else {
uploadedFile = this.getValueFromData(files) || this.getValueFromData(files, '_upload');
}
// Ensure a valid file was uploaded, else null out the value
if (uploadedFile && !uploadedFile.path) {
uploadedFile = undefined;
}
// If we have a file to upload, we do that and stop here
if (uploadedFile) {
var tagPrefix = keystone.get('cloudinary prefix') || '';
var uploadOptions = {
tags: [],
};
if (tagPrefix.length) {
uploadOptions.tags.push(tagPrefix);
tagPrefix += '_';
}
uploadOptions.tags.push(tagPrefix + field.list.path + '_' + field.path);
if (keystone.get('env') !== 'production') {
uploadOptions.tags.push(tagPrefix + 'dev');
}
var folder = this.getFolder();
if (folder) {
uploadOptions.folder = folder;
}
this.getFilename(uploadedFile, function (err, filename) {
if (err) return callback(err);
// If an undefined filename is returned, Cloudinary will automatically generate a unique
// filename. Therefore undefined is a valid filename value.
if (filename !== undefined) {
filename = sanitize(filename);
uploadOptions.public_id = trimSupportedFileExtensions(filename);
}
cloudinary.uploader.upload(uploadedFile.path, function (result) {
if (result.error) {
return callback(result.error);
} else {
item.set(field.path, result);
return callback();
}
}, uploadOptions);
});
return;
}
// Empty / null values reset the field
if (value === null || value === '' || (typeof value === 'object' && !Object.keys(value).length)) {
value = getEmptyValue();
}
// If there is a valid value at this point, set it on the field
if (typeof value === 'object') {
item.set(this.path, value);
}
utils.defer(callback);
};
/**
Generates a filename with the provided method in a retry loop, used by
getFilename below
*/
cloudinaryimage.prototype.retryFilename = prototypeMethods.retryFilename;
/**
Gets a filename for uploaded files based on the adapter options
*/
cloudinaryimage.prototype.getFilename = prototypeMethods.getFilename;
cloudinaryimage.prototype.fileExists = function (filename, callback) {
var cloudinary = require('cloudinary');
cloudinary.api.resource(filename, function (result) {
if (result.error && result.error.http_code === 404) {
// File doesn't exist
callback(null, false);
} else if (result.error) {
// Error
callback(result.error, null);
} else {
// File exists
callback(null, true);
}
});
};
/**
* Returns a callback that handles a standard form submission for the field
*
* Expected form parts are
* - `field.paths.action` in `req.body` (`clear` or `delete`)
* - `field.paths.upload` in `req.files` (uploads the image to cloudinary)
*
* @api public
*/
cloudinaryimage.prototype.getRequestHandler = function (item, req, paths, callback) {
var cloudinary = require('cloudinary');
var field = this;
if (utils.isFunction(paths)) {
callback = paths;
paths = field.paths;
} else if (!paths) {
paths = field.paths;
}
callback = callback || function () {};
return function () {
if (req.body) {
var action = req.body[paths.action];
if (/^(delete|reset)$/.test(action)) {
field.apply(item, action);
}
}
if (req.body && req.body[paths.select]) {
cloudinary.api.resource(req.body[paths.select], function (result) {
if (result.error) {
callback(result.error);
} else {
item.set(field.path, result);
callback();
}
});
} else if (req.files && req.files[paths.upload] && req.files[paths.upload].size) {
var tp = keystone.get('cloudinary prefix') || '';
var imageDelete;
if (tp.length) {
tp += '_';
}
var uploadOptions = {
tags: [tp + field.list.path + '_' + field.path, tp + field.list.path + '_' + field.path + '_' + item.id],
};
if (keystone.get('cloudinary folders')) {
uploadOptions.folder = item.get(paths.folder);
}
if (keystone.get('cloudinary prefix')) {
uploadOptions.tags.push(keystone.get('cloudinary prefix'));
}
if (keystone.get('env') !== 'production') {
uploadOptions.tags.push(tp + 'dev');
}
if (field.options.publicID) {
var publicIdValue = item.get(field.options.publicID);
if (publicIdValue) {
uploadOptions.public_id = publicIdValue;
}
} else if (field.options.filenameAsPublicID) {
uploadOptions.public_id = req.files[paths.upload].originalname.substring(0, req.files[paths.upload].originalname.lastIndexOf('.'));
}
if (field.options.autoCleanup && item.get(field.paths.exists)) {
// capture image delete promise
imageDelete = field.apply(item, 'delete');
}
// callback to be called upon completion of the 'upload' method
var uploadComplete = function (result) {
if (result.error) {
callback(result.error);
} else {
item.set(field.path, result);
callback();
}
};
// upload immediately if image is not being delete
if (typeof imageDelete === 'undefined') {
field.apply(item, 'upload', req.files[paths.upload].path, uploadOptions).then(uploadComplete);
} else {
// otherwise wait until image is deleted before uploading
// this avoids problems when deleting/uploading images with the same public_id (issue #598)
imageDelete.then(function (result) {
if (result.error) {
callback(result.error);
} else {
field.apply(item, 'upload', req.files[paths.upload].path, uploadOptions).then(uploadComplete);
}
});
}
} else {
callback();
}
};
};
/* Export Field Type */
module.exports = cloudinaryimage;