diff --git a/.jshintrc b/.jshintrc index e96a07d..765e3d1 100644 --- a/.jshintrc +++ b/.jshintrc @@ -125,6 +125,7 @@ //Internals "S3Policy": false, - "Slingshot": true + "Slingshot": true, + "matchAllowedFileTypes": false } } diff --git a/README.md b/README.md index 351c279..027b673 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,36 @@ Slingshot.createDirective("myFileUploads", Slingshot.S3Storage, { This directive will not allow any files other than images to be uploaded. The policy is directed by the meteor app server and enforced by AWS S3. +## Client side validation + +On both client and server side we declare file restrictions for our directive: + +```Javascript +Slingshot.fileRestrictions("myFileUploads", { + allowedFileTypes: ["image/png", "image/jpeg", "image/gif"], + maxSize: 1*0x400*0x400, //1MB, + authorize: function() { + return this.userId + } +}); +``` + +Now Slingshot will validate the file before sending the authorization request to the server. + + +### Manual validation +```JavaScript +var uploader = new Slingshot.Upload("myFileUploads"); + +var error = uploader.validate(document.getElementById('input').files[0]); +if (error) { + console.error(error); +} +``` + +The validate method will return `null` if valid and returns an `Error instance` if validation fails. + + ## Storage services The client side is agnostic to which storage service is used. All it @@ -213,27 +243,26 @@ Meteor core packages: ### Directives -`authorize`: Function (required) - Function to determines if upload is allowed. +`authorize`: Function (**required** unless set in File Restrictions) -`maxSize`: Number (required) - Maximum file-size (in bytes). Use `null` or `0` -for unlimited. +`maxSize`: Number (**required** unless set in File Restrictions) -`allowedFileTypes` RegExp, String or Array (required) - Allowed MIME types. Use -null for any file type. +`allowedFileTypes` RegExp, String or Array (**required** unless set in File +Restrictions) `cacheControl` String (optional) - RFC 2616 Cache-Control directive -`contentDisposition` String (required) - RFC 2616 Content-Disposition directive. +`contentDisposition` String (**required**) - RFC 2616 Content-Disposition directive. Default is the uploaded file's name (inline). Use null to disable. -`bucket` String (required) - Name of bucket to use. Google Cloud it default is +`bucket` String (**required**) - Name of bucket to use. Google Cloud it default is `Meteor.settings.GoogleCloudBucket`. For AWS S3 the default bucket is `Meteor.settings.S3Bucket`. `domain` String (optional) - Override domain to use to access bucket. Useful for CDN. -`key` String or Function (required) - Name of the file on the cloud storage +`key` String or Function (**required**) - Name of the file on the cloud storage service. If a function is provided, it will be called with `userId` in the context and its return value is used as the key. @@ -242,14 +271,24 @@ authorization will expire after the request was made. Default is 5 minutes. `acl` String (optional) -`AWSAccessKeyId` String (required for AWS S3) - Can also be set in +`AWSAccessKeyId` String (**required** for AWS S3) - Can also be set in `Meteor.settings` -`AWSSecretAccessKey` String (required for AWS S3) - Can also be set in +`AWSSecretAccessKey` String (**required** for AWS S3) - Can also be set in `Meteor.settings` -`GoogleAccessId` String (required for Google Cloud Storage) - Can also be set in +`GoogleAccessId` String (**required** for Google Cloud Storage) - Can also be set in `Meteor.settings` -`GoogleSecretKey` String (required for Google Cloud Storage) - Can also be set +`GoogleSecretKey` String (**required** for Google Cloud Storage) - Can also be set in `Meteor.settings` + +### File restrictions + +`authorize`: Function (optional) - Function to determines if upload is allowed. + +`maxSize`: Number (optional) - Maximum file-size (in bytes). Use `null` or `0` +for unlimited. + +`allowedFileTypes` RegExp, String or Array (optional) - Allowed MIME types. Use +null for any file type. diff --git a/lib/directive.js b/lib/directive.js index 5a5eefa..22a9803 100644 --- a/lib/directive.js +++ b/lib/directive.js @@ -1,10 +1,3 @@ -/** - * @module meteor-slingshot - */ - -Slingshot = {}; - - /** * @callback Directive~authorize * @@ -81,6 +74,9 @@ Slingshot.createDirective = function (name, service, options) { if (_.has(Slingshot._directives, name)) throw new Error("Directive '" + name + "' already exists"); + var restrictions = Slingshot.getRestrictions(name); + _.defaults(options, restrictions); + return (Slingshot._directives[name] = new Slingshot.Directive(service, options)); }; @@ -95,9 +91,6 @@ Slingshot.getDirective = function (name) { return this._directives[name]; }; - -var matchAllowedFileTypes = Match.OneOf(String, [String], RegExp, null); - /** * @param {Object} service * @param {Directive} directive @@ -142,79 +135,6 @@ Slingshot.Directive = function (service, directive) { _.extend(Slingshot.Directive.prototype, { - /** - * - * @method requestAuthorization - * - * @throws Meteor.Error - * - * @param {FileInfo} file - * @param {Object} [meta] - * - * @returns {Boolean} - */ - - requestAuthorization: function (method, file, meta) { - return this.checkFileSize(file.size) && this.checkFileType(file.type) && - this._directive.authorize.call(method, file, meta); - }, - - /** - * @throws Meteor.Error - * - * @param {Number} size - Size of file in bytes. - * @returns {boolean} - */ - - checkFileSize: function (size) { - var maxSize = Math.min(this._directive.maxSize, - this.storageService().maxSize || Infinity); - - if (maxSize && size > maxSize) - throw new Meteor.Error("Upload denied", "File exceeds allowed size of " + - formatBytes(maxSize)); - - return true; - }, - - /** - * - * @throws Meteor.Error - * - * @param {String} type - Mime type - * @returns {boolean} - */ - - checkFileType: function (type) { - var allowed = this._directive.allowedFileTypes; - - if (allowed instanceof RegExp) { - - if (!allowed.test(type)) - throw new Meteor.Error("Upload denied", - type + " is not an allowed file type"); - - return true; - } - - if (_.isArray(allowed)) { - if (allowed.indexOf(type) < 0) { - throw new Meteor.Error("Upload denied", - type + " is not one of the followed allowed file types: " + - allowed.join(", ")); - } - - return true; - } - - if (allowed !== type) { - throw new Meteor.Error("Upload denied", "Only file of type " + allowed + - " can be uploaded"); - } - - return true; - }, - /** * @param {{userId: String}} method * @param {FileInfo} file @@ -238,7 +158,30 @@ _.extend(Slingshot.Directive.prototype, { }); return instructions; + }, + + /** + * + * @method requestAuthorization + * + * @throws Meteor.Error + * + * @param {Object} context + * @param {FileInfo} file + * @param {Object} [meta] + * + * @returns {Boolean} + */ + + requestAuthorization: function (context, file, meta) { + var validators = Slingshot.Validators, + restrictions = _.pick(this._directive, + ['authorize', 'maxSize', 'allowedFileTypes'] + ); + + return validators.checkAll(context, file, meta, restrictions); } + }); Meteor.methods({ @@ -284,26 +227,6 @@ Meteor.methods({ } }); - -/** Human readable data-size in bytes. - * - * @param size {Number} - * @returns {string} - */ - -function formatBytes(size) { - var units = ['Bytes', 'KB', 'MB', 'GB', 'TB'], - unit = units.shift(); - - while (size >= 0x400 && units.length) { - size /= 0x400; - unit = units.shift(); - } - - return (Math.round(size * 100) / 100) + " " + unit; -} - - function quoteString(string, quotes) { return quotes + string.replace(quotes, '\\' + quotes) + quotes; } diff --git a/lib/restrictions.js b/lib/restrictions.js new file mode 100644 index 0000000..c187daa --- /dev/null +++ b/lib/restrictions.js @@ -0,0 +1,53 @@ +/** + * @module meteor-slingshot + */ + +Slingshot = {}; + +/* global matchAllowedFileTypes: true */ +matchAllowedFileTypes = Match.OneOf(String, [String], RegExp, null); + +/** + * List of configured restrictions by name. + * + * @type {Object.} + * @private + */ + +Slingshot._restrictions = {}; + +/** + * Creates file upload restrictions for a specific directive. + * + * @param {string} name - A unique identifier of the directive. + * @param {Object} restrictions - The file upload restrictions. + * @returns {Object} + */ + +Slingshot.fileRestrictions = function (name, restrictions) { + check(restrictions, { + authorize: Match.Optional(Function), + maxSize: Match.Optional(Match.OneOf(Number, null)), + allowedFileTypes: Match.Optional(matchAllowedFileTypes), + }); + + if (Meteor.isServer) { + var directive = Slingshot.getDirective(name); + if (directive) { + _.extend(directive._directive, restrictions); + } + } + + return (Slingshot._restrictions[name] = + _.extend(Slingshot._restrictions[name] || {}, restrictions)); +}; + +/** + * @param {string} name - The unique identifier of the directive to + * retrieve the restrictions for. + * @returns {Object} + */ + +Slingshot.getRestrictions = function (name) { + return this._restrictions[name] || {}; +}; \ No newline at end of file diff --git a/lib/upload.js b/lib/upload.js index c834fbd..b5c0ccd 100644 --- a/lib/upload.js +++ b/lib/upload.js @@ -2,8 +2,6 @@ * @fileOverview Defines client side API in which files can be uploaded. */ -Slingshot = {}; - /** * * @param {string} directive - Name of server-directive to use. @@ -62,6 +60,25 @@ Slingshot.Upload = function (directive, metaData) { return loaded.get(); }, + /** + * @param {File} file + * @returns {null|Error} Returns null on success, Error on failure. + */ + + validate: function(file) { + var context = { + userId: Meteor.userId() + }; + try { + var validators = Slingshot.Validators, + restrictions = Slingshot.getRestrictions(directive); + + validators.checkAll(context, file, metaData, restrictions) && null; + } catch(error) { + return error; + } + }, + /** * @param {File} file * @param {Function} [callback] @@ -69,7 +86,7 @@ Slingshot.Upload = function (directive, metaData) { */ send: function (file, callback) { - if(window.chrome && window.navigator.vendor === "Google Inc.") { + if (window.chrome && window.navigator.vendor === "Google Inc.") { check(file, window.File); } else { // not Google chrome @@ -103,13 +120,20 @@ Slingshot.Upload = function (directive, metaData) { callback(new Error("No file to request upload for")); } + var file = _.pick(self.file, "name", "size", "type"); + status.set("authorizing"); + var error = this.validate(file); + if (error) { + status.set("failed"); + callback(error); + return self; + } + Meteor.call("slingshot/uploadRequest", directive, - _.pick(self.file, "name", "size", "type"), metaData, function (error, - instructions) { + file, metaData, function (error, instructions) { status.set(error ? "failed" : "authorized"); - callback(error, instructions); }); @@ -246,6 +270,7 @@ Slingshot.Upload = function (directive, metaData) { return field && field.value; } + }); }; diff --git a/lib/validators.js b/lib/validators.js new file mode 100644 index 0000000..ffebc2f --- /dev/null +++ b/lib/validators.js @@ -0,0 +1,96 @@ +Slingshot.Validators = { + + /** + * + * @method checkAll + * + * @throws Meteor.Error + * + * @param {Object} context + * @param {FileInfo} file + * @param {Object} [meta] + * @param {Object} [restrictions] + * + * @returns {Boolean} + */ + + checkAll: function (context, file, meta, restrictions) { + return this.checkFileSize(file.size, restrictions.maxSize) && + this.checkFileType(file.type, restrictions.allowedFileTypes) && + (typeof restrictions.authorize !== 'function' || + restrictions.authorize.call(context, file, meta)); + }, + + /** + * @throws Meteor.Error + * + * @param {Number} size - Size of file in bytes. + * @param {Number} maxSize - Max size of file in bytes. + * @returns {boolean} + */ + + checkFileSize: function (size, maxSize) { + maxSize = Math.min(maxSize, Infinity); + + if (maxSize && size > maxSize) + throw new Meteor.Error("Upload denied", "File exceeds allowed size of " + + formatBytes(maxSize)); + + return true; + }, + + /** + * + * @throws Meteor.Error + * + * @param {String} type - Mime type + * @param {(RegExp|Array|String)} [allowed] - Allowed file type(s) + * @returns {boolean} + */ + + checkFileType: function (type, allowed) { + if (allowed instanceof RegExp) { + + if (!allowed.test(type)) + throw new Meteor.Error("Upload denied", + type + " is not an allowed file type"); + + return true; + } + + if (_.isArray(allowed)) { + if (allowed.indexOf(type) < 0) { + throw new Meteor.Error("Upload denied", + type + " is not one of the followed allowed file types: " + + allowed.join(", ")); + } + + return true; + } + + if (allowed && allowed !== type) { + throw new Meteor.Error("Upload denied", "Only files of type " + allowed + + " can be uploaded"); + } + + return true; + } +}; + +/** Human readable data-size in bytes. + * + * @param size {Number} + * @returns {string} + */ + +function formatBytes(size) { + var units = ['Bytes', 'KB', 'MB', 'GB', 'TB'], + unit = units.shift(); + + while (size >= 0x400 && units.length) { + size /= 0x400; + unit = units.shift(); + } + + return (Math.round(size * 100) / 100) + " " + unit; +} diff --git a/package.js b/package.js index 5d09165..8fc9191 100644 --- a/package.js +++ b/package.js @@ -11,7 +11,13 @@ Package.on_use(function (api) { api.use(["underscore", "check"]); api.use(["tracker", "reactive-var"], "client"); + api.add_files([ + "lib/restrictions.js", + "lib/validators.js" + ]); + api.add_files("lib/upload.js", "client"); + api.add_files([ "lib/directive.js", "lib/storage-policy.js",