From 6c99b67ab3b0b1ca9a03d2677acf629d15ccdcdb Mon Sep 17 00:00:00 2001 From: Gabor Javorszky Date: Thu, 12 Sep 2013 21:03:18 +0100 Subject: [PATCH] Added client side validation Closes #581. * Basically adds the client side of node validator, that we're already using * Validator is plonked onto `Ghost.Validator` * Usage is identical as to https://github.com/chriso/node-validator * Has sanitizing values et al * `Ghost.Validator.error` is redefined, it populates Ghost.Validator._errors (Array) * `Ghost.Validator.handleErrors` is supposed to print out the multiple error messages, if there are multiple (this is broken due to how notifications are presented `.html` instead of `.append`), and also apply class to element * The ajax calls are wrapped in an if to prevent network traffic if something's not right on client side * Added validation to general settings and user settings screens. * On validation error, optionally adds `.input-error` to whatever element you reference, see below (if `el` exists on the error object). This is the only place where usage is different to the original implementation. Redeclared `error()` function in `init.js` * Usage: `Ghost.Validate.check(valueToCheck, {message: "the error message", el: $('#the element')}).isEmail()` * The element above will receive the `.input-error` class. `isEmail()` is one of the stuff you can check against. --- core/client/assets/vendor/validator-client.js | 1010 +++++++++++++++++ core/client/init.js | 24 +- core/client/tpl/settings/user-profile.hbs | 2 +- core/client/views/login.js | 114 +- core/client/views/settings.js | 175 ++- core/server/views/default.hbs | 3 +- 6 files changed, 1209 insertions(+), 119 deletions(-) create mode 100644 core/client/assets/vendor/validator-client.js diff --git a/core/client/assets/vendor/validator-client.js b/core/client/assets/vendor/validator-client.js new file mode 100644 index 0000000000..70c13a0099 --- /dev/null +++ b/core/client/assets/vendor/validator-client.js @@ -0,0 +1,1010 @@ +/*! + * Copyright (c) 2010 Chris O'Hara + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +// follow Universal Module Definition (UMD) pattern for defining module as AMD, CommonJS, and Browser compatible +(function (root, factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['exports'], factory); + } else if (typeof exports === 'object') { + // CommonJS + factory(exports); + } else { + // Browser globals + // N.B. Here is a slight difference to regular UMD as the current API for node-validator in browser adds each export directly to the window + // rather than to a namespaced object such as window.nodeValidator, which would be better practice, but would break backwards compatibility + // as such unable to use build tools like grunt-umd + factory(root); + } +}(this, function(exports) { + + var entities = { + ' ': '\u00a0', + '¡': '\u00a1', + '¢': '\u00a2', + '£': '\u00a3', + '¤': '\u20ac', + '¥': '\u00a5', + '¦': '\u0160', + '§': '\u00a7', + '¨': '\u0161', + '©': '\u00a9', + 'ª': '\u00aa', + '«': '\u00ab', + '¬': '\u00ac', + '­': '\u00ad', + '®': '\u00ae', + '¯': '\u00af', + '°': '\u00b0', + '±': '\u00b1', + '²': '\u00b2', + '³': '\u00b3', + '´': '\u017d', + 'µ': '\u00b5', + '¶': '\u00b6', + '·': '\u00b7', + '¸': '\u017e', + '¹': '\u00b9', + 'º': '\u00ba', + '»': '\u00bb', + '¼': '\u0152', + '½': '\u0153', + '¾': '\u0178', + '¿': '\u00bf', + 'À': '\u00c0', + 'Á': '\u00c1', + 'Â': '\u00c2', + 'Ã': '\u00c3', + 'Ä': '\u00c4', + 'Å': '\u00c5', + 'Æ': '\u00c6', + 'Ç': '\u00c7', + 'È': '\u00c8', + 'É': '\u00c9', + 'Ê': '\u00ca', + 'Ë': '\u00cb', + 'Ì': '\u00cc', + 'Í': '\u00cd', + 'Î': '\u00ce', + 'Ï': '\u00cf', + 'Ð': '\u00d0', + 'Ñ': '\u00d1', + 'Ò': '\u00d2', + 'Ó': '\u00d3', + 'Ô': '\u00d4', + 'Õ': '\u00d5', + 'Ö': '\u00d6', + '×': '\u00d7', + 'Ø': '\u00d8', + 'Ù': '\u00d9', + 'Ú': '\u00da', + 'Û': '\u00db', + 'Ü': '\u00dc', + 'Ý': '\u00dd', + 'Þ': '\u00de', + 'ß': '\u00df', + 'à': '\u00e0', + 'á': '\u00e1', + 'â': '\u00e2', + 'ã': '\u00e3', + 'ä': '\u00e4', + 'å': '\u00e5', + 'æ': '\u00e6', + 'ç': '\u00e7', + 'è': '\u00e8', + 'é': '\u00e9', + 'ê': '\u00ea', + 'ë': '\u00eb', + 'ì': '\u00ec', + 'í': '\u00ed', + 'î': '\u00ee', + 'ï': '\u00ef', + 'ð': '\u00f0', + 'ñ': '\u00f1', + 'ò': '\u00f2', + 'ó': '\u00f3', + 'ô': '\u00f4', + 'õ': '\u00f5', + 'ö': '\u00f6', + '÷': '\u00f7', + 'ø': '\u00f8', + 'ù': '\u00f9', + 'ú': '\u00fa', + 'û': '\u00fb', + 'ü': '\u00fc', + 'ý': '\u00fd', + 'þ': '\u00fe', + 'ÿ': '\u00ff', + '"': '\u0022', + '<': '\u003c', + '>': '\u003e', + ''': '\u0027', + '−': '\u2212', + 'ˆ': '\u02c6', + '˜': '\u02dc', + 'Š': '\u0160', + '‹': '\u2039', + 'Œ': '\u0152', + '‘': '\u2018', + '’': '\u2019', + '“': '\u201c', + '”': '\u201d', + '•': '\u2022', + '–': '\u2013', + '—': '\u2014', + '™': '\u2122', + 'š': '\u0161', + '›': '\u203a', + 'œ': '\u0153', + 'Ÿ': '\u0178', + 'ƒ': '\u0192', + 'Α': '\u0391', + 'Β': '\u0392', + 'Γ': '\u0393', + 'Δ': '\u0394', + 'Ε': '\u0395', + 'Ζ': '\u0396', + 'Η': '\u0397', + 'Θ': '\u0398', + 'Ι': '\u0399', + 'Κ': '\u039a', + 'Λ': '\u039b', + 'Μ': '\u039c', + 'Ν': '\u039d', + 'Ξ': '\u039e', + 'Ο': '\u039f', + 'Π': '\u03a0', + 'Ρ': '\u03a1', + 'Σ': '\u03a3', + 'Τ': '\u03a4', + 'Υ': '\u03a5', + 'Φ': '\u03a6', + 'Χ': '\u03a7', + 'Ψ': '\u03a8', + 'Ω': '\u03a9', + 'α': '\u03b1', + 'β': '\u03b2', + 'γ': '\u03b3', + 'δ': '\u03b4', + 'ε': '\u03b5', + 'ζ': '\u03b6', + 'η': '\u03b7', + 'θ': '\u03b8', + 'ι': '\u03b9', + 'κ': '\u03ba', + 'λ': '\u03bb', + 'μ': '\u03bc', + 'ν': '\u03bd', + 'ξ': '\u03be', + 'ο': '\u03bf', + 'π': '\u03c0', + 'ρ': '\u03c1', + 'ς': '\u03c2', + 'σ': '\u03c3', + 'τ': '\u03c4', + 'υ': '\u03c5', + 'φ': '\u03c6', + 'χ': '\u03c7', + 'ψ': '\u03c8', + 'ω': '\u03c9', + 'ϑ': '\u03d1', + 'ϒ': '\u03d2', + 'ϖ': '\u03d6', + ' ': '\u2002', + ' ': '\u2003', + ' ': '\u2009', + '‌': '\u200c', + '‍': '\u200d', + '‎': '\u200e', + '‏': '\u200f', + '‚': '\u201a', + '„': '\u201e', + '†': '\u2020', + '‡': '\u2021', + '…': '\u2026', + '‰': '\u2030', + '′': '\u2032', + '″': '\u2033', + '‾': '\u203e', + '⁄': '\u2044', + '€': '\u20ac', + 'ℑ': '\u2111', + '℘': '\u2118', + 'ℜ': '\u211c', + 'ℵ': '\u2135', + '←': '\u2190', + '↑': '\u2191', + '→': '\u2192', + '↓': '\u2193', + '↔': '\u2194', + '↵': '\u21b5', + '⇐': '\u21d0', + '⇑': '\u21d1', + '⇒': '\u21d2', + '⇓': '\u21d3', + '⇔': '\u21d4', + '∀': '\u2200', + '∂': '\u2202', + '∃': '\u2203', + '∅': '\u2205', + '∇': '\u2207', + '∈': '\u2208', + '∉': '\u2209', + '∋': '\u220b', + '∏': '\u220f', + '∑': '\u2211', + '∗': '\u2217', + '√': '\u221a', + '∝': '\u221d', + '∞': '\u221e', + '∠': '\u2220', + '∧': '\u2227', + '∨': '\u2228', + '∩': '\u2229', + '∪': '\u222a', + '∫': '\u222b', + '∴': '\u2234', + '∼': '\u223c', + '≅': '\u2245', + '≈': '\u2248', + '≠': '\u2260', + '≡': '\u2261', + '≤': '\u2264', + '≥': '\u2265', + '⊂': '\u2282', + '⊃': '\u2283', + '⊄': '\u2284', + '⊆': '\u2286', + '⊇': '\u2287', + '⊕': '\u2295', + '⊗': '\u2297', + '⊥': '\u22a5', + '⋅': '\u22c5', + '⌈': '\u2308', + '⌉': '\u2309', + '⌊': '\u230a', + '⌋': '\u230b', + '⟨': '\u2329', + '⟩': '\u232a', + '◊': '\u25ca', + '♠': '\u2660', + '♣': '\u2663', + '♥': '\u2665', + '♦': '\u2666' + }; + + var decode = function (str) { + if (!~str.indexOf('&')) return str; + + //Decode literal entities + for (var i in entities) { + str = str.replace(new RegExp(i, 'g'), entities[i]); + } + + //Decode hex entities + str = str.replace(/&#x(0*[0-9a-f]{2,5});?/gi, function (m, code) { + return String.fromCharCode(parseInt(+code, 16)); + }); + + //Decode numeric entities + str = str.replace(/&#([0-9]{2,4});?/gi, function (m, code) { + return String.fromCharCode(+code); + }); + + str = str.replace(/&/g, '&'); + + return str; + } + + var encode = function (str) { + str = str.replace(/&/g, '&'); + + //IE doesn't accept ' + str = str.replace(/'/g, '''); + + //Encode literal entities + for (var i in entities) { + str = str.replace(new RegExp(entities[i], 'g'), i); + } + + return str; + } + + exports.entities = { + encode: encode, + decode: decode + } + + //This module is adapted from the CodeIgniter framework + //The license is available at http://codeigniter.com/ + + var never_allowed_str = { + 'document.cookie': '', + 'document.write': '', + '.parentNode': '', + '.innerHTML': '', + 'window.location': '', + '-moz-binding': '', + '': '-->', + ' 901119URL5918AMP18930PROTECT8198 + str = str.replace(/\&([a-z\_0-9]+)\=([a-z\_0-9]+)/i, xss_hash() + '$1=$2'); + + //Validate standard character entities - add a semicolon if missing. We do this to enable + //the conversion of entities to ASCII later. + str = str.replace(/(&\#?[0-9a-z]{2,})([\x00-\x20])*;?/i, '$1;$2'); + + //Validate UTF16 two byte encoding (x00) - just as above, adds a semicolon if missing. + str = str.replace(/(&\#x?)([0-9A-F]+);?/i, '$1;$2'); + + //Un-protect query string variables + str = str.replace(xss_hash(), '&'); + + //Decode just in case stuff like this is submitted: + //Google + try { + str = decodeURIComponent(str); + } catch (e) { + // str was not actually URI-encoded + } + + //Convert character entities to ASCII - this permits our tests below to work reliably. + //We only convert entities that are within tags since these are the ones that will pose security problems. + str = str.replace(/[a-z]+=([\'\"]).*?\1/gi, function(m, match) { + return m.replace(match, convert_attribute(match)); + }); + + //Remove invisible characters again + str = remove_invisible_characters(str); + + //Convert tabs to spaces + str = str.replace('\t', ' '); + + //Captured the converted string for later comparison + var converted_string = str; + + //Remove strings that are never allowed + for (var i in never_allowed_str) { + str = str.replace(i, never_allowed_str[i]); + } + + //Remove regex patterns that are never allowed + for (var i in never_allowed_regex) { + str = str.replace(new RegExp(i, 'i'), never_allowed_regex[i]); + } + + //Compact any exploded words like: j a v a s c r i p t + // We only want to do this when it is followed by a non-word character + for (var i in compact_words) { + var spacified = compact_words[i].split('').join('\\s*')+'\\s*'; + + str = str.replace(new RegExp('('+spacified+')(\\W)', 'ig'), function(m, compat, after) { + return compat.replace(/\s+/g, '') + after; + }); + } + + //Remove disallowed Javascript in links or img tags + do { + var original = str; + + if (str.match(/]*?)(>|$)/gi, function(m, attributes, end_tag) { + attributes = filter_attributes(attributes.replace('<','').replace('>','')); + return m.replace(attributes, attributes.replace(/href=.*?(alert\(|alert&\#40;|javascript\:|charset\=|window\.|document\.|\.cookie|]*?)(\s?\/?>|$)/gi, function(m, attributes, end_tag) { + attributes = filter_attributes(attributes.replace('<','').replace('>','')); + return m.replace(attributes, attributes.replace(/src=.*?(alert\(|alert&\#40;|javascript\:|charset\=|window\.|document\.|\.cookie|/gi, ''); + } + + } while(original != str); + + //Remove JavaScript Event Handlers - Note: This code is a little blunt. It removes the event + //handler and anything up to the closing >, but it's unlikely to be a problem. + event_handlers = ['[^a-z_\-]on\\w*']; + + //Adobe Photoshop puts XML metadata into JFIF images, including namespacing, + //so we have to allow this for images + if (!is_image) { + event_handlers.push('xmlns'); + } + + str = str.replace(new RegExp("<([^><]+?)("+event_handlers.join('|')+")(\\s*=\\s*[^><]*)([><]*)", 'i'), '<$1$4'); + + //Sanitize naughty HTML elements + //If a tag containing any of the words in the list + //below is found, the tag gets converted to entities. + //So this: + //Becomes: <blink> + naughty = 'alert|applet|audio|basefont|base|behavior|bgsound|blink|body|embed|expression|form|frameset|frame|head|html|ilayer|iframe|input|isindex|layer|link|meta|object|plaintext|style|script|textarea|title|video|xml|xss'; + str = str.replace(new RegExp('<(/*\\s*)('+naughty+')([^><]*)([><]*)', 'gi'), function(m, a, b, c, d) { + return '<' + a + b + c + d.replace('>','>').replace('<','<'); + }); + + //Sanitize naughty scripting elements Similar to above, only instead of looking for + //tags it looks for PHP and JavaScript commands that are disallowed. Rather than removing the + //code, it simply converts the parenthesis to entities rendering the code un-executable. + //For example: eval('some code') + //Becomes: eval('some code') + str = str.replace(/(alert|cmd|passthru|eval|exec|expression|system|fopen|fsockopen|file|file_get_contents|readfile|unlink)(\s*)\((.*?)\)/gi, '$1$2($3)'); + + //This adds a bit of extra precaution in case something got through the above filters + for (var i in never_allowed_str) { + str = str.replace(i, never_allowed_str[i]); + } + for (var i in never_allowed_regex) { + str = str.replace(new RegExp(i, 'i'), never_allowed_regex[i]); + } + + //Images are handled in a special way + if (is_image && str !== converted_string) { + throw new Error('Image may contain XSS'); + } + + return str; + } + + function remove_invisible_characters(str) { + for (var i in non_displayables) { + str = str.replace(non_displayables[i], ''); + } + return str; + } + + function xss_hash() { + //TODO: Create a random hash + return '!*$^#(@*#&'; + } + + function convert_attribute(str) { + return str.replace('>','>').replace('<','<').replace('\\','\\\\'); + } + + //Filter Attributes - filters tag attributes for consistency and safety + function filter_attributes(str) { + var comments = /\/\*.*?\*\//g; + return str.replace(/\s*[a-z-]+\s*=\s*'[^']*'/gi, function (m) { + return m.replace(comments, ''); + }).replace(/\s*[a-z-]+\s*=\s*"[^"]*"/gi, function (m) { + return m.replace(comments, ''); + }).replace(/\s*[a-z-]+\s*=\s*[^\s]+/gi, function (m) { + return m.replace(comments, ''); + }); + } + + var Validator = exports.Validator = function() {} + + Validator.prototype.check = function(str, fail_msg) { + this.str = typeof( str ) === 'undefined' || str === null || (isNaN(str) && str.length === undefined) ? '' : str+''; + this.msg = fail_msg; + this._errors = this._errors || []; + return this; + } + + function internal_is_ipv4(str) { + if (/^(\d?\d?\d)\.(\d?\d?\d)\.(\d?\d?\d)\.(\d?\d?\d)$/.test(str)) { + var parts = str.split('.').sort(); + // no need to check for < 0 as regex won't match in that case + if (parts[3] > 255) { + return false; + } + return true; + } + return false; + } + + function internal_is_ipv6(str) { + if (/^::|^::1|^([a-fA-F0-9]{1,4}::?){1,7}([a-fA-F0-9]{1,4})$/.test(str)) { + return true; + } + return false; + } + + //Create some aliases - may help code readability + Validator.prototype.validate = Validator.prototype.check; + Validator.prototype.assert = Validator.prototype.check; + + Validator.prototype.error = function(msg) { + throw new Error(msg); + } + + function toDate(date) { + if (date instanceof Date) { + return date; + } + var intDate = Date.parse(date); + if (isNaN(intDate)) { + return null; + } + return new Date(intDate); + } + + Validator.prototype.isAfter = function(date) { + date = date || new Date(); + var origDate = toDate(this.str) + , compDate = toDate(date); + if (!(origDate && compDate && origDate >= compDate)) { + return this.error(this.msg || 'Invalid date'); + } + return this; + }; + + Validator.prototype.isBefore = function(date) { + date = date || new Date(); + var origDate = toDate(this.str) + , compDate = toDate(date); + if (!(origDate && compDate && origDate <= compDate)) { + return this.error(this.msg || 'Invalid date'); + } + return this; + }; + + Validator.prototype.isEmail = function() { + if (!this.str.match(/^(?:[\w\!\#\$\%\&\'\*\+\-\/\=\?\^\`\{\|\}\~]+\.)*[\w\!\#\$\%\&\'\*\+\-\/\=\?\^\`\{\|\}\~]+@(?:(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-](?!\.)){0,61}[a-zA-Z0-9]?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9\-](?!$)){0,61}[a-zA-Z0-9]?)|(?:\[(?:(?:[01]?\d{1,2}|2[0-4]\d|25[0-5])\.){3}(?:[01]?\d{1,2}|2[0-4]\d|25[0-5])\]))$/)) { + return this.error(this.msg || 'Invalid email'); + } + return this; + } + + //Will work against Visa, MasterCard, American Express, Discover, Diners Club, and JCB card numbering formats + Validator.prototype.isCreditCard = function() { + this.str = this.str.replace(/[^0-9]+/g, ''); //remove all dashes, spaces, etc. + if (!this.str.match(/^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\d{3})\d{11})$/)) { + return this.error(this.msg || 'Invalid credit card'); + } + // Doing Luhn check + var sum = 0; + var digit; + var tmpNum; + var shouldDouble = false; + for (var i = this.length - 1; i >= 0; i--) { + digit = this.substring(i, (i + 1)); + tmpNum = parseInt(digit, 10); + if (shouldDouble) { + tmpNum *= 2; + if (tmpNum >= 10) { + sum += ((tmpNum % 10) + 1); + } + else { + sum += tmpNum; + } + } + else { + sum += tmpNum; + } + if (shouldDouble) { + shouldDouble = false; + } + else { + shouldDouble = true; + } + } + if ((sum % 10) !== 0) { + return this.error(this.msg || 'Invalid credit card'); + } + return this; + } + + Validator.prototype.isUrl = function() { + if (!this.str.match(/^(?!mailto:)(?:(?:https?|ftp):\/\/)?(?:\S+(?::\S*)?@)?(?:(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))|localhost)(?::\d{2,5})?(?:\/[^\s]*)?$/i) || this.str.length > 2083) { + return this.error(this.msg || 'Invalid URL'); + } + return this; + } + + Validator.prototype.isIPv4 = function() { + if (internal_is_ipv4(this.str)) { + return this; + } + return this.error(this.msg || 'Invalid IP'); + } + + Validator.prototype.isIPv6 = function() { + if (internal_is_ipv6(this.str)) { + return this; + } + return this.error(this.msg || 'Invalid IP'); + } + + Validator.prototype.isIP = function() { + if (internal_is_ipv4(this.str) || internal_is_ipv6(this.str)) { + return this; + } + return this.error(this.msg || 'Invalid IP'); + } + + Validator.prototype.isAlpha = function() { + if (!this.str.match(/^[a-zA-Z]+$/)) { + return this.error(this.msg || 'Invalid characters'); + } + return this; + } + + Validator.prototype.isAlphanumeric = function() { + if (!this.str.match(/^[a-zA-Z0-9]+$/)) { + return this.error(this.msg || 'Invalid characters'); + } + return this; + } + + Validator.prototype.isNumeric = function() { + if (!this.str.match(/^-?[0-9]+$/)) { + return this.error(this.msg || 'Invalid number'); + } + return this; + } + + Validator.prototype.isHexadecimal = function() { + if (!this.str.match(/^[0-9a-fA-F]+$/)) { + return this.error(this.msg || 'Invalid hexadecimal'); + } + return this; + } + + Validator.prototype.isHexColor = function() { + if (!this.str.match(/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/)) { + return this.error(this.msg || 'Invalid hexcolor'); + } + return this; + } + + Validator.prototype.isLowercase = function() { + if (this.str !== this.str.toLowerCase()) { + return this.error(this.msg || 'Invalid characters'); + } + return this; + } + + Validator.prototype.isUppercase = function() { + if (this.str !== this.str.toUpperCase()) { + return this.error(this.msg || 'Invalid characters'); + } + return this; + } + + Validator.prototype.isInt = function() { + if (!this.str.match(/^(?:-?(?:0|[1-9][0-9]*))$/)) { + return this.error(this.msg || 'Invalid integer'); + } + return this; + } + + Validator.prototype.isDecimal = function() { + if (!this.str.match(/^(?:-?(?:0|[1-9][0-9]*))?(?:\.[0-9]*)?$/)) { + return this.error(this.msg || 'Invalid decimal'); + } + return this; + } + + Validator.prototype.isDivisibleBy = function(n) { + return (parseFloat(this.str) % parseInt(n, 10)) === 0; + } + + Validator.prototype.isFloat = function() { + return this.isDecimal(); + } + + Validator.prototype.notNull = function() { + if (this.str === '') { + return this.error(this.msg || 'String is empty'); + } + return this; + } + + Validator.prototype.isNull = function() { + if (this.str !== '') { + return this.error(this.msg || 'String is not empty'); + } + return this; + } + + Validator.prototype.notEmpty = function() { + if (this.str.match(/^[\s\t\r\n]*$/)) { + return this.error(this.msg || 'String is whitespace'); + } + return this; + } + + Validator.prototype.equals = function(equals) { + if (this.str != equals) { + return this.error(this.msg || 'Not equal'); + } + return this; + } + + Validator.prototype.contains = function(str) { + if (this.str.indexOf(str) === -1 || !str) { + return this.error(this.msg || 'Invalid characters'); + } + return this; + } + + Validator.prototype.notContains = function(str) { + if (this.str.indexOf(str) >= 0) { + return this.error(this.msg || 'Invalid characters'); + } + return this; + } + + Validator.prototype.regex = Validator.prototype.is = function(pattern, modifiers) { + if (Object.prototype.toString.call(pattern).slice(8, -1) !== 'RegExp') { + pattern = new RegExp(pattern, modifiers); + } + if (! this.str.match(pattern)) { + return this.error(this.msg || 'Invalid characters'); + } + return this; + } + + Validator.prototype.notRegex = Validator.prototype.not = function(pattern, modifiers) { + if (Object.prototype.toString.call(pattern).slice(8, -1) !== 'RegExp') { + pattern = new RegExp(pattern, modifiers); + } + if (this.str.match(pattern)) { + this.error(this.msg || 'Invalid characters'); + } + return this; + } + + Validator.prototype.len = function(min, max) { + if (this.str.length < min) { + return this.error(this.msg || 'String is too small'); + } + if (typeof max !== undefined && this.str.length > max) { + return this.error(this.msg || 'String is too large'); + } + return this; + } + + //Thanks to github.com/sreuter for the idea. + Validator.prototype.isUUID = function(version) { + var pattern; + if (version == 3 || version == 'v3') { + pattern = /[0-9A-F]{8}-[0-9A-F]{4}-3[0-9A-F]{3}-[0-9A-F]{4}-[0-9A-F]{12}$/i; + } else if (version == 4 || version == 'v4') { + pattern = /[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i; + } else if (version == 5 || version == 'v5') { + pattern = /^[0-9A-F]{8}-[0-9A-F]{4}-5[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i; + } else { + pattern = /[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i; + } + if (!this.str.match(pattern)) { + return this.error(this.msg || 'Not a UUID'); + } + return this; + } + + Validator.prototype.isUUIDv3 = function() { + return this.isUUID(3); + } + + Validator.prototype.isUUIDv4 = function() { + return this.isUUID(4); + } + + Validator.prototype.isUUIDv5 = function() { + return this.isUUID(5); + } + + Validator.prototype.isDate = function() { + var intDate = Date.parse(this.str); + if (isNaN(intDate)) { + return this.error(this.msg || 'Not a date'); + } + return this; + } + + Validator.prototype.isIn = function(options) { + if (options && typeof options.indexOf === 'function') { + if (!~options.indexOf(this.str)) { + return this.error(this.msg || 'Unexpected value'); + } + return this; + } else { + return this.error(this.msg || 'Invalid in() argument'); + } + } + + Validator.prototype.notIn = function(options) { + if (options && typeof options.indexOf === 'function') { + if (options.indexOf(this.str) !== -1) { + return this.error(this.msg || 'Unexpected value'); + } + return this; + } else { + return this.error(this.msg || 'Invalid notIn() argument'); + } + } + + Validator.prototype.min = function(val) { + var number = parseFloat(this.str); + + if (!isNaN(number) && number < val) { + return this.error(this.msg || 'Invalid number'); + } + + return this; + } + + Validator.prototype.max = function(val) { + var number = parseFloat(this.str); + if (!isNaN(number) && number > val) { + return this.error(this.msg || 'Invalid number'); + } + return this; + } + + var Filter = exports.Filter = function() {} + + var whitespace = '\\r\\n\\t\\s'; + + Filter.prototype.modify = function(str) { + this.str = str; + } + + //Create some aliases - may help code readability + Filter.prototype.convert = Filter.prototype.sanitize = function(str) { + this.str = str == null ? '' : str + ''; + return this; + } + + Filter.prototype.xss = function(is_image) { + this.modify(exports.xssClean(this.str, is_image)); + return this.str; + } + + Filter.prototype.entityDecode = function() { + this.modify(decode(this.str)); + return this.str; + } + + Filter.prototype.entityEncode = function() { + this.modify(encode(this.str)); + return this.str; + } + + Filter.prototype.escape = function() { + this.modify(this.str.replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>')); + return this.str; + }; + + Filter.prototype.ltrim = function(chars) { + chars = chars || whitespace; + this.modify(this.str.replace(new RegExp('^['+chars+']+', 'g'), '')); + return this.str; + } + + Filter.prototype.rtrim = function(chars) { + chars = chars || whitespace; + this.modify(this.str.replace(new RegExp('['+chars+']+$', 'g'), '')); + return this.str; + } + + Filter.prototype.trim = function(chars) { + chars = chars || whitespace; + this.modify(this.str.replace(new RegExp('^['+chars+']+|['+chars+']+$', 'g'), '')); + return this.str; + } + + Filter.prototype.ifNull = function(replace) { + if (!this.str || this.str === '') { + this.modify(replace); + } + return this.str; + } + + Filter.prototype.toFloat = function() { + this.modify(parseFloat(this.str)); + return this.str; + } + + Filter.prototype.toInt = function(radix) { + radix = radix || 10; + this.modify(parseInt(this.str, radix)); + return this.str; + } + + //Any strings with length > 0 (except for '0' and 'false') are considered true, + //all other strings are false + Filter.prototype.toBoolean = function() { + if (!this.str || this.str == '0' || this.str == 'false' || this.str == '') { + this.modify(false); + } else { + this.modify(true); + } + return this.str; + } + + //String must be equal to '1' or 'true' to be considered true, all other strings + //are false + Filter.prototype.toBooleanStrict = function() { + if (this.str == '1' || this.str == 'true') { + this.modify(true); + } else { + this.modify(false); + } + return this.str; + } + + //Quick access methods + exports.sanitize = exports.convert = function(str) { + var filter = new exports.Filter(); + return filter.sanitize(str); + } + + exports.check = exports.validate = exports.assert = function(str, fail_msg) { + var validator = new exports.Validator(); + return validator.check(str, fail_msg); + } + + return exports; + +})); diff --git a/core/client/init.js b/core/client/init.js index ca71dc0052..c50019325f 100644 --- a/core/client/init.js +++ b/core/client/init.js @@ -1,4 +1,4 @@ -/*globals window, $, _, Backbone */ +/*globals window, $, _, Backbone, Validator */ (function () { "use strict"; @@ -7,6 +7,7 @@ Views : {}, Collections : {}, Models : {}, + Validate : new Validator(), settings: { apiRoot: '/api/v0.1' @@ -36,6 +37,25 @@ }); }; + Ghost.Validate.error = function (object) { + this._errors.push(object); + + return this; + }; + + Ghost.Validate.handleErrors = function () { + _.each(Ghost.Validate._errors, function (errorObj) { + Ghost.notifications.addItem({ + type: 'error', + message: errorObj.message, + status: 'passive' + }); + if (errorObj.hasOwnProperty('el')) { + errorObj.el.addClass('input-error'); + } + }); + }; + window.Ghost = Ghost; -}()); \ No newline at end of file +}()); diff --git a/core/client/tpl/settings/user-profile.hbs b/core/client/tpl/settings/user-profile.hbs index da58a9e3d5..2aa4019cb4 100644 --- a/core/client/tpl/settings/user-profile.hbs +++ b/core/client/tpl/settings/user-profile.hbs @@ -69,7 +69,7 @@
- +
diff --git a/core/client/views/login.js b/core/client/views/login.js index 78b8388edc..f566c81bda 100644 --- a/core/client/views/login.js +++ b/core/client/views/login.js @@ -23,25 +23,33 @@ password = this.$el.find('.password').val(), redirect = Ghost.Views.Utils.getUrlVariables().r; - $.ajax({ - url: '/ghost/signin/', - type: 'POST', - data: { - email: email, - password: password, - redirect: redirect - }, - success: function (msg) { - window.location.href = msg.redirect; - }, - error: function (xhr) { - Ghost.notifications.addItem({ - type: 'error', - message: Ghost.Views.Utils.getRequestErrorMessage(xhr), - status: 'passive' - }); - } - }); + Ghost.Validate._errors = []; + Ghost.Validate.check(email).isEmail(); + Ghost.Validate.check(password, "Password too short").len(5); + + if (Ghost.Validate._errors.length > 0) { + Ghost.Validate.handleErrors(); + } else { + $.ajax({ + url: '/ghost/signin/', + type: 'POST', + data: { + email: email, + password: password, + redirect: redirect + }, + success: function (msg) { + window.location.href = msg.redirect; + }, + error: function (xhr) { + Ghost.notifications.addItem({ + type: 'error', + message: Ghost.Views.Utils.getRequestErrorMessage(xhr), + status: 'passive' + }); + } + }); + } } }); @@ -66,24 +74,15 @@ email = this.$el.find('.email').val(), password = this.$el.find('.password').val(); - if (!name) { - Ghost.notifications.addItem({ - type: 'error', - message: "Please enter a name", - status: 'passive' - }); - } else if (!email) { - Ghost.notifications.addItem({ - type: 'error', - message: "Please enter an email", - status: 'passive' - }); - } else if (!password) { - Ghost.notifications.addItem({ - type: 'error', - message: "Please enter a password", - status: 'passive' - }); + // This is needed due to how error handling is done. If this is not here, there will not be a time + // when there is no error. + Ghost.Validate._errors = []; + Ghost.Validate.check(name, "Please enter a name").len(1); + Ghost.Validate.check(email, "Please enter a correct email address").isEmail(); + Ghost.Validate.check(password, "Please enter a password").len(5); + + if (Ghost.Validate._errors.length > 0) { + Ghost.Validate.handleErrors(); } else { $.ajax({ url: '/ghost/signup/', @@ -128,24 +127,31 @@ var email = this.$el.find('.email').val(); - $.ajax({ - url: '/ghost/forgotten/', - type: 'POST', - data: { - email: email - }, - success: function (msg) { + Ghost.Validate._errors = []; + Ghost.Validate.check(email).isEmail(); - window.location.href = msg.redirect; - }, - error: function (xhr) { - Ghost.notifications.addItem({ - type: 'error', - message: Ghost.Views.Utils.getRequestErrorMessage(xhr), - status: 'passive' - }); - } - }); + if (Ghost.Validate._errors.length > 0) { + Ghost.Validate.handleErrors(); + } else { + $.ajax({ + url: '/ghost/forgotten/', + type: 'POST', + data: { + email: email + }, + success: function (msg) { + + window.location.href = msg.redirect; + }, + error: function (xhr) { + Ghost.notifications.addItem({ + type: 'error', + message: Ghost.Views.Utils.getRequestErrorMessage(xhr), + status: 'passive' + }); + } + }); + } } }); }()); diff --git a/core/client/views/settings.js b/core/client/views/settings.js index 406c71c4ed..d9b5e75d97 100644 --- a/core/client/views/settings.js +++ b/core/client/views/settings.js @@ -157,21 +157,45 @@ }, saveSettings: function () { - var themes = this.model.get('availableThemes'); - this.model.unset('availableThemes'); - this.model.save({ - title: this.$('#blog-title').val(), - description: $('#blog-description').val(), - logo: this.$('#blog-logo').attr("src"), - cover: this.$('#blog-cover').attr("src"), - email: this.$('#email-address').val(), - postsPerPage: this.$('#postsPerPage').val(), - activeTheme: this.$('#activeTheme').val() - }, { - success: this.saveSuccess, - error: this.saveError - }); - this.model.set({availableThemes: themes}); + var themes = this.model.get('availableThemes'), + title = this.$('#blog-title').val(), + description = this.$('#blog-description').val(), + email = this.$('#email-address').val(), + postsPerPage = this.$('#postsPerPage').val(); + + Ghost.Validate._errors = []; + Ghost.Validate + .check(title, {message: "Title is too long", el: $('#blog-title')}) + .len(0, 150); + Ghost.Validate + .check(description, {message: "Description is too long", el: $('#blog-description')}) + .len(0, 200); + Ghost.Validate + .check(email, {message: "Please supply a valid email address", el: $('#email-address')}) + .isEmail().len(0, 254); + Ghost.Validate + .check(postsPerPage, {message: "Please use a number", el: $('postsPerPage')}) + .isInt(); + + if (Ghost.Validate._errors.length > 0) { + Ghost.Validate.handleErrors(); + } else { + + this.model.unset('availableThemes'); + this.model.save({ + title: title, + description: description, + logo: this.$('#blog-logo').attr("src"), + cover: this.$('#blog-cover').attr("src"), + email: email, + postsPerPage: postsPerPage, + activeTheme: this.$('#activeTheme').val() + }, { + success: this.saveSuccess, + error: this.saveError + }); + this.model.set({availableThemes: themes}); + } }, showLogo: function () { var settings = this.model.toJSON(); @@ -259,63 +283,92 @@ saveUser: function () { - this.model.save({ - 'full_name': this.$('#user-name').val(), - 'email_address': this.$('#user-email').val(), - 'location': this.$('#user-location').val(), - 'url': this.$('#user-website').val(), - 'bio': this.$('#user-bio').val(), - 'profile_picture': this.$('#user-profile-picture').attr('src'), - 'cover_picture': this.$('#user-cover-picture').attr('src') - }, { - success: this.saveSuccess, - error: this.saveError - }); + var userName = this.$('#user-name').val(), + userEmail = this.$('#user-email').val(), + userLocation = this.$('#user-location').val(), + userWebsite = this.$('#user-website').val(), + userBio = this.$('#user-bio').val(); + + Ghost.Validate._errors = []; + Ghost.Validate + .check(userName, {message: "Name is too long", el: $('#user-name')}) + .len(0, 150); + Ghost.Validate + .check(userBio, {message: "Bio is too long", el: $('#user-bio')}) + .len(0, 200); + Ghost.Validate + .check(userEmail, {message: "Please supply a valid email address", el: $('#user-email')}) + .isEmail(); + Ghost.Validate + .check(userLocation, {message: "Location is too long", el: $('#user-location')}) + .len(0, 150); + if (userWebsite.length > 0) { + Ghost.Validate + .check(userWebsite, {message: "Please use a valid url", el: $('#user-website')}) + .isUrl() + .len(0, 2000); + } + + if (Ghost.Validate._errors.length > 0) { + Ghost.Validate.handleErrors(); + } else { + + this.model.save({ + 'full_name': userName, + 'email_address': userEmail, + 'location': userLocation, + 'url': userWebsite, + 'bio': userBio, + 'profile_picture': this.$('#user-profile-picture').attr('src'), + 'cover_picture': this.$('#user-cover-picture').attr('src') + }, { + success: this.saveSuccess, + error: this.saveError + }); + } }, changePassword: function (event) { event.preventDefault(); - var self = this, oldPassword = this.$('#user-password-old').val(), newPassword = this.$('#user-password-new').val(), ne2Password = this.$('#user-new-password-verification').val(); - if (newPassword !== ne2Password) { - this.validationError('Your new passwords do not match'); - return; - } + Ghost.Validate._errors = []; + Ghost.Validate.check(newPassword, {message: 'Your new passwords do not match'}).equals(ne2Password); + Ghost.Validate.check(newPassword, {message: 'Your password is not long enough. It must be at least 8 chars long.'}).len(8); - if (newPassword.length < 8) { - this.validationError('Your password is not long enough. It must be at least 8 chars long.'); - return; - } + if (Ghost.Validate._errors.length > 0) { + Ghost.Validate.handleErrors(); + } else { - $.ajax({ - url: '/ghost/changepw/', - type: 'POST', - data: { - password: oldPassword, - newpassword: newPassword, - ne2password: ne2Password - }, - success: function (msg) { - Ghost.notifications.addItem({ - type: 'success', - message: msg.msg, - status: 'passive', - id: 'success-98' - }); - self.$('#user-password-old, #user-password-new, #user-new-password-verification').val(''); - }, - error: function (xhr) { - Ghost.notifications.addItem({ - type: 'error', - message: Ghost.Views.Utils.getRequestErrorMessage(xhr), - status: 'passive' - }); - } - }); + $.ajax({ + url: '/ghost/changepw/', + type: 'POST', + data: { + password: oldPassword, + newpassword: newPassword, + ne2password: ne2Password + }, + success: function (msg) { + Ghost.notifications.addItem({ + type: 'success', + message: msg.msg, + status: 'passive', + id: 'success-98' + }); + self.$('#user-password-old, #user-password-new, #user-new-password-verification').val(''); + }, + error: function (xhr) { + Ghost.notifications.addItem({ + type: 'error', + message: Ghost.Views.Utils.getRequestErrorMessage(xhr), + status: 'passive' + }); + } + }); + } }, templateName: 'settings/user-profile', diff --git a/core/server/views/default.hbs b/core/server/views/default.hbs index 0ba88e1553..f0e94a3530 100644 --- a/core/server/views/default.hbs +++ b/core/server/views/default.hbs @@ -58,6 +58,7 @@ + @@ -83,8 +84,8 @@ - +