From ac7f4f05c43196cc1117e0dc44bbafabefabad0f Mon Sep 17 00:00:00 2001 From: Sebastian Gierlinger Date: Wed, 19 Feb 2014 18:32:23 +0100 Subject: [PATCH 01/27] Add validation from schema.js closes #1401 - added data/validation/index.js - added generic validation for length - added generic validation for nullable - added validations object to schema.js for custom validation - removed pyramid of doom from api/db.js --- core/server/api/db.js | 34 +++-------- core/server/data/schema.js | 20 +++---- core/server/data/validation/index.js | 89 ++++++++++++++++++++++++++++ core/server/models/base.js | 5 +- core/server/models/permission.js | 6 +- core/server/models/post.js | 8 +-- core/server/models/role.js | 5 -- core/server/models/settings.js | 34 +---------- core/server/models/tag.js | 5 -- core/server/models/user.js | 14 +---- core/test/unit/import_spec.js | 8 +-- 11 files changed, 122 insertions(+), 106 deletions(-) create mode 100644 core/server/data/validation/index.js diff --git a/core/server/api/db.js b/core/server/api/db.js index 645dee5bfe..236275889c 100644 --- a/core/server/api/db.js +++ b/core/server/api/db.js @@ -6,7 +6,7 @@ var dataExport = require('../data/export'), when = require('when'), nodefn = require('when/node/function'), _ = require('lodash'), - schema = require('../data/schema').tables, + validation = require('../data/validation'), config = require('../config'), api = {}, @@ -69,8 +69,7 @@ db = { return nodefn.call(fs.readFile, options.importfile.path); }).then(function (fileContents) { var importData, - error = '', - tableKeys = _.keys(schema); + error = ''; // Parse the json data try { @@ -83,28 +82,13 @@ db = { return when.reject(new Error("Import data does not specify version")); } - _.each(tableKeys, function (constkey) { - _.each(importData.data[constkey], function (elem) { - var prop; - for (prop in elem) { - if (elem.hasOwnProperty(prop)) { - if (schema[constkey].hasOwnProperty(prop)) { - if (!_.isNull(elem[prop])) { - if (elem[prop].length > schema[constkey][prop].maxlength) { - error += error !== "" ? "
" : ""; - error += "Property '" + prop + "' exceeds maximum length of " + schema[constkey][prop].maxlength + " (element:" + constkey + " / id:" + elem.id + ")"; - } - } else { - if (!schema[constkey][prop].nullable) { - error += error !== "" ? "
" : ""; - error += "Property '" + prop + "' is not nullable (element:" + constkey + " / id:" + elem.id + ")"; - } - } - } else { - error += error !== "" ? "
" : ""; - error += "Property '" + prop + "' is not allowed (element:" + constkey + " / id:" + elem.id + ")"; - } - } + _.each(_.keys(importData.data), function (tableName) { + _.each(importData.data[tableName], function (importValues) { + try { + validation.validateSchema(tableName, importValues); + } catch (err) { + error += error !== "" ? "
" : ""; + error += err.message; } }); }); diff --git a/core/server/data/schema.js b/core/server/data/schema.js index 4d31dbda87..1734d0d1f9 100644 --- a/core/server/data/schema.js +++ b/core/server/data/schema.js @@ -1,14 +1,14 @@ var db = { posts: { id: {type: 'increments', nullable: false, primary: true}, - uuid: {type: 'string', maxlength: 36, nullable: false}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {'isUUID': true}}, title: {type: 'string', maxlength: 150, nullable: false}, slug: {type: 'string', maxlength: 150, nullable: false, unique: true}, markdown: {type: 'text', maxlength: 16777215, fieldtype: 'medium', nullable: true}, html: {type: 'text', maxlength: 16777215, fieldtype: 'medium', nullable: true}, image: {type: 'text', maxlength: 2000, nullable: true}, featured: {type: 'bool', nullable: false, defaultTo: false}, - page: {type: 'bool', nullable: false, defaultTo: false}, + page: {type: 'bool', nullable: false, defaultTo: false, validations: {'isIn': ['true', 'false']}}, status: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'draft'}, language: {type: 'string', maxlength: 6, nullable: false, defaultTo: 'en_US'}, meta_title: {type: 'string', maxlength: 150, nullable: true}, @@ -23,15 +23,15 @@ var db = { }, users: { id: {type: 'increments', nullable: false, primary: true}, - uuid: {type: 'string', maxlength: 36, nullable: false}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {'isUUID': true}}, name: {type: 'string', maxlength: 150, nullable: false, unique: true}, slug: {type: 'string', maxlength: 150, nullable: false}, password: {type: 'string', maxlength: 60, nullable: false}, - email: {type: 'string', maxlength: 254, nullable: false, unique: true}, + email: {type: 'string', maxlength: 254, nullable: false, unique: true, validations: {'isEmail': true}}, image: {type: 'text', maxlength: 2000, nullable: true}, cover: {type: 'text', maxlength: 2000, nullable: true}, bio: {type: 'string', maxlength: 200, nullable: true}, - website: {type: 'text', maxlength: 2000, nullable: true}, + website: {type: 'text', maxlength: 2000, nullable: true, validations: {'isUrl': true}}, location: {type: 'text', maxlength: 65535, nullable: true}, accessibility: {type: 'text', maxlength: 65535, nullable: true}, status: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'active'}, @@ -46,7 +46,7 @@ var db = { }, roles: { id: {type: 'increments', nullable: false, primary: true}, - uuid: {type: 'string', maxlength: 36, nullable: false}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {'isUUID': true}}, name: {type: 'string', maxlength: 150, nullable: false}, description: {type: 'string', maxlength: 200, nullable: true}, created_at: {type: 'dateTime', nullable: false}, @@ -61,7 +61,7 @@ var db = { }, permissions: { id: {type: 'increments', nullable: false, primary: true}, - uuid: {type: 'string', maxlength: 36, nullable: false}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {'isUUID': true}}, name: {type: 'string', maxlength: 150, nullable: false}, object_type: {type: 'string', maxlength: 150, nullable: false}, action_type: {type: 'string', maxlength: 150, nullable: false}, @@ -88,10 +88,10 @@ var db = { }, settings: { id: {type: 'increments', nullable: false, primary: true}, - uuid: {type: 'string', maxlength: 36, nullable: false}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {'isUUID': true}}, key: {type: 'string', maxlength: 150, nullable: false, unique: true}, value: {type: 'text', maxlength: 65535, nullable: true}, - type: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'core'}, + type: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'core', validations: {'isIn': ['core', 'blog', 'theme', 'app', 'plugin']}}, created_at: {type: 'dateTime', nullable: false}, created_by: {type: 'integer', nullable: false}, updated_at: {type: 'dateTime', nullable: true}, @@ -99,7 +99,7 @@ var db = { }, tags: { id: {type: 'increments', nullable: false, primary: true}, - uuid: {type: 'string', maxlength: 36, nullable: false}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {'isUUID': true}}, name: {type: 'string', maxlength: 150, nullable: false}, slug: {type: 'string', maxlength: 150, nullable: false, unique: true}, description: {type: 'string', maxlength: 200, nullable: true}, diff --git a/core/server/data/validation/index.js b/core/server/data/validation/index.js new file mode 100644 index 0000000000..25dd00f7ff --- /dev/null +++ b/core/server/data/validation/index.js @@ -0,0 +1,89 @@ +var schema = require('../schema').tables, + _ = require('lodash'), + validator = require('validator'), + when = require('when'), + + validateSchema, + validateSettings, + validate; + +// Validation validation against schema attributes +// values are checked against the validation objects +// form schema.js +validateSchema = function (tableName, model) { + var columns = _.keys(schema[tableName]); + + _.each(columns, function (columnKey) { + // check nullable + if (model.hasOwnProperty(columnKey) && schema[tableName][columnKey].hasOwnProperty('nullable') + && schema[tableName][columnKey].nullable !== true) { + validator.check(model[columnKey], 'Value in [' + tableName + '.' + columnKey + + '] cannot be blank.').notNull(); + validator.check(model[columnKey], 'Value in [' + tableName + '.' + columnKey + + '] cannot be blank.').notEmpty(); + } + // TODO: check if mandatory values should be enforced + if (model[columnKey]) { + // check length + if (schema[tableName][columnKey].hasOwnProperty('maxlength')) { + validator.check(model[columnKey], 'Value in [' + tableName + '.' + columnKey + + '] exceeds maximum length of %2 characters.').len(0, schema[tableName][columnKey].maxlength); + } + + //check validations objects + if (schema[tableName][columnKey].hasOwnProperty('validations')) { + validate(model[columnKey], columnKey, schema[tableName][columnKey].validations); + } + + //check type + if (schema[tableName][columnKey].hasOwnProperty('type')) { + if (schema[tableName][columnKey].type === 'integer') { + validator.check(model[columnKey], 'Value in [' + tableName + '.' + columnKey + + '] is no valid integer.' + model[columnKey]).isInt(); + } + } + } + }); +}; + +// Validation for settings +// settings are checked against the validation objects +// form default-settings.json +validateSettings = function (defaultSettings, model) { + var values = model.toJSON(), + matchingDefault = defaultSettings[values.key]; + + if (matchingDefault && matchingDefault.validations) { + validate(values.value, values.key, matchingDefault.validations); + } +}; + +// Validate using the validation module. +// Each validation's key is a name and its value is an array of options +// Use true (boolean) if options aren't applicable +// +// eg: +// validations: { isUrl: true, len: [20, 40] } +// +// will validate that a values's length is a URL between 20 and 40 chars, +// available validators: https://github.com/chriso/node-validator#list-of-validation-methods +validate = function (value, key, validations) { + _.each(validations, function (validationOptions, validationName) { + var validation = validator.check(value, 'Validation [' + validationName + '] of field [' + key + '] failed.'); + + if (validationOptions === true) { + validationOptions = null; + } + if (typeof validationOptions !== 'array') { + validationOptions = [validationOptions]; + } + + // equivalent of validation.isSomething(option1, option2) + validation[validationName].apply(validation, validationOptions); + }, this); +}; + +module.exports = { + validateSchema: validateSchema, + validateSettings: validateSettings +}; \ No newline at end of file diff --git a/core/server/models/base.js b/core/server/models/base.js index 47d47e9b3f..4eab1d6a2b 100644 --- a/core/server/models/base.js +++ b/core/server/models/base.js @@ -4,10 +4,10 @@ var Bookshelf = require('bookshelf'), _ = require('lodash'), uuid = require('node-uuid'), config = require('../config'), - Validator = require('validator').Validator, unidecode = require('unidecode'), sanitize = require('validator').sanitize, schema = require('../data/schema'), + validation = require('../data/validation'), ghostBookshelf; @@ -15,7 +15,6 @@ var Bookshelf = require('bookshelf'), ghostBookshelf = Bookshelf.ghost = Bookshelf.initialize(config().database); ghostBookshelf.client = config().database.client; -ghostBookshelf.validator = new Validator(); // The Base Model which other Ghost objects will inherit from, // including some convenience functions as static properties on the model. @@ -45,7 +44,7 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({ }, validate: function () { - return true; + validation.validateSchema(this.tableName, this.toJSON()); }, creating: function () { diff --git a/core/server/models/permission.js b/core/server/models/permission.js index 77064be098..f84f5be610 100644 --- a/core/server/models/permission.js +++ b/core/server/models/permission.js @@ -1,6 +1,7 @@ var ghostBookshelf = require('./base'), User = require('./user').User, Role = require('./role').Role, + validation = require('../data/validation'), Permission, Permissions; @@ -9,11 +10,6 @@ Permission = ghostBookshelf.Model.extend({ tableName: 'permissions', - validate: function () { - // TODO: validate object_type, action_type and object_id - ghostBookshelf.validator.check(this.get('name'), "Permission name cannot be blank").notEmpty(); - }, - roles: function () { return this.belongsToMany(Role); }, diff --git a/core/server/models/post.js b/core/server/models/post.js index 1deb5640ea..d0f4b2c180 100644 --- a/core/server/models/post.js +++ b/core/server/models/post.js @@ -9,6 +9,7 @@ var _ = require('lodash'), Tag = require('./tag').Tag, Tags = require('./tag').Tags, ghostBookshelf = require('./base'), + validation = require('../data/validation'), Post, Posts, @@ -37,12 +38,7 @@ Post = ghostBookshelf.Model.extend({ }, validate: function () { - ghostBookshelf.validator.check(this.get('title'), "Post title cannot be blank").notEmpty(); - ghostBookshelf.validator.check(this.get('title'), 'Post title maximum length is 150 characters.').len(0, 150); - ghostBookshelf.validator.check(this.get('slug'), "Post title cannot be blank").notEmpty(); - ghostBookshelf.validator.check(this.get('slug'), 'Post title maximum length is 150 characters.').len(0, 150); - - return true; + validation.validateSchema(this.tableName, this.toJSON()); }, saving: function (newPage, attr, options) { diff --git a/core/server/models/role.js b/core/server/models/role.js index 66729ef11a..f9d134e441 100644 --- a/core/server/models/role.js +++ b/core/server/models/role.js @@ -9,11 +9,6 @@ Role = ghostBookshelf.Model.extend({ tableName: 'roles', - validate: function () { - ghostBookshelf.validator.check(this.get('name'), "Role name cannot be blank").notEmpty(); - ghostBookshelf.validator.check(this.get('description'), "Role description cannot be blank").notEmpty(); - }, - users: function () { return this.belongsToMany(User); }, diff --git a/core/server/models/settings.js b/core/server/models/settings.js index 49a38bdfcf..74a5557925 100644 --- a/core/server/models/settings.js +++ b/core/server/models/settings.js @@ -1,10 +1,10 @@ var Settings, ghostBookshelf = require('./base'), - validator = ghostBookshelf.validator, uuid = require('node-uuid'), _ = require('lodash'), errors = require('../errorHandling'), when = require('when'), + validation = require('../data/validation'), defaultSettings; @@ -41,37 +41,9 @@ Settings = ghostBookshelf.Model.extend({ }; }, - - // Validate default settings using the validator module. - // Each validation's key is a name and its value is an array of options - // Use true (boolean) if options aren't applicable - // - // eg: - // validations: { isUrl: true, len: [20, 40] } - // - // will validate that a setting's length is a URL between 20 and 40 chars, - // available validators: https://github.com/chriso/node-validator#list-of-validation-methods validate: function () { - validator.check(this.get('key'), "Setting key cannot be blank").notEmpty(); - validator.check(this.get('type'), "Setting type cannot be blank").notEmpty(); - - var matchingDefault = defaultSettings[this.get('key')]; - - if (matchingDefault && matchingDefault.validations) { - _.each(matchingDefault.validations, function (validationOptions, validationName) { - var validation = validator.check(this.get('value')); - - if (validationOptions === true) { - validationOptions = null; - } - if (typeof validationOptions !== 'array') { - validationOptions = [validationOptions]; - } - - // equivalent of validation.isSomething(option1, option2) - validation[validationName].apply(validation, validationOptions); - }, this); - } + validation.validateSchema(this.tableName, this.toJSON()); + validation.validateSettings(defaultSettings, this); }, diff --git a/core/server/models/tag.js b/core/server/models/tag.js index a251dc0e3b..e1daff07fa 100644 --- a/core/server/models/tag.js +++ b/core/server/models/tag.js @@ -8,11 +8,6 @@ Tag = ghostBookshelf.Model.extend({ tableName: 'tags', - validate: function () { - - return true; - }, - saving: function () { var self = this; ghostBookshelf.Model.prototype.saving.apply(this, arguments); diff --git a/core/server/models/user.js b/core/server/models/user.js index 165738f57e..0405ae6f50 100644 --- a/core/server/models/user.js +++ b/core/server/models/user.js @@ -10,6 +10,7 @@ var _ = require('lodash'), Permission = require('./permission').Permission, http = require('http'), crypto = require('crypto'), + validator = require('validator'), tokenSecurity = {}, User, @@ -17,11 +18,10 @@ var _ = require('lodash'), function validatePasswordLength(password) { try { - ghostBookshelf.validator.check(password, "Your password must be at least 8 characters long.").len(8); + validator.check(password, "Your password must be at least 8 characters long.").len(8); } catch (error) { return when.reject(error); } - return when.resolve(); } @@ -37,16 +37,6 @@ User = ghostBookshelf.Model.extend({ tableName: 'users', - validate: function () { - ghostBookshelf.validator.check(this.get('email'), "Please enter a valid email address. That one looks a bit dodgy.").isEmail(); - ghostBookshelf.validator.check(this.get('bio'), "We're not writing a novel here! I'm afraid your bio has to stay under 200 characters.").len(0, 200); - if (this.get('website') && this.get('website').length > 0) { - ghostBookshelf.validator.check(this.get('website'), "Looks like your website is not actually a website. Try again?").isUrl(); - } - ghostBookshelf.validator.check(this.get('location'), 'This seems a little too long! Please try and keep your location under 150 characters.').len(0, 150); - return true; - }, - saving: function () { var self = this; // disabling sanitization until we can implement a better version diff --git a/core/test/unit/import_spec.js b/core/test/unit/import_spec.js index f5b4c90f82..804e95e284 100644 --- a/core/test/unit/import_spec.js +++ b/core/test/unit/import_spec.js @@ -246,7 +246,7 @@ describe("Import", function () { }).then(function () { (1).should.eql(0, 'Data import should not resolve promise.'); }, function (error) { - error.should.eql('Error importing data: Post title maximum length is 150 characters.'); + error.should.eql('Error importing data: Value in [posts.title] exceeds maximum length of 150 characters.'); when.all([ knex("users").select(), @@ -292,7 +292,7 @@ describe("Import", function () { }).then(function () { (1).should.eql(0, 'Data import should not resolve promise.'); }, function (error) { - error.should.eql('Error importing data: Setting key cannot be blank'); + error.should.eql('Error importing data: Value in [settings.key] cannot be blank.'); when.all([ knex("users").select(), @@ -433,7 +433,7 @@ describe("Import", function () { }).then(function () { (1).should.eql(0, 'Data import should not resolve promise.'); }, function (error) { - error.should.eql('Error importing data: Post title maximum length is 150 characters.'); + error.should.eql('Error importing data: Value in [posts.title] exceeds maximum length of 150 characters.'); when.all([ knex("users").select(), @@ -479,7 +479,7 @@ describe("Import", function () { }).then(function () { (1).should.eql(0, 'Data import should not resolve promise.'); }, function (error) { - error.should.eql('Error importing data: Setting key cannot be blank'); + error.should.eql('Error importing data: Value in [settings.key] cannot be blank.'); when.all([ knex("users").select(), From 2cb02b55e1140e325fa5df320615179287572141 Mon Sep 17 00:00:00 2001 From: Harry Wolff Date: Sat, 22 Feb 2014 21:16:07 -0500 Subject: [PATCH 02/27] Custom Page Templates fixes #1969 - creates new ./server/helpers/tempalte.js method which returns the correct view to use when rendering - updates fronted controller to check if a custom page template exists and if so then uses that to render the static page - adds additional class name to body_class helper when a custom page template is being rendered - adds tests to address all new features --- core/server/controllers/frontend.js | 4 +- core/server/helpers/index.js | 24 +++- core/server/helpers/template.js | 22 +++ core/test/unit/frontend_spec.js | 130 +++++++++++------- core/test/unit/server_helpers_index_spec.js | 44 +++++- .../test/unit/server_helpers_template_spec.js | 36 +++++ 6 files changed, 198 insertions(+), 62 deletions(-) diff --git a/core/server/controllers/frontend.js b/core/server/controllers/frontend.js index 3a60e6c06c..609d6e6f8c 100644 --- a/core/server/controllers/frontend.js +++ b/core/server/controllers/frontend.js @@ -15,6 +15,7 @@ var moment = require('moment'), config = require('../config'), errors = require('../errorHandling'), filters = require('../../server/filters'), + template = require('../helpers/template'), frontendControllers, // Cache static post permalink regex @@ -186,7 +187,8 @@ frontendControllers = { filters.doFilter('prePostsRender', post).then(function (post) { api.settings.read('activeTheme').then(function (activeTheme) { var paths = config().paths.availableThemes[activeTheme.value], - view = post.page && paths.hasOwnProperty('page.hbs') ? 'page' : 'post'; + view = template.getThemeViewForPost(paths, post); + res.render(view, {post: post}); }); }); diff --git a/core/server/helpers/index.js b/core/server/helpers/index.js index f34c9b2b16..5ed9d58af9 100644 --- a/core/server/helpers/index.js +++ b/core/server/helpers/index.js @@ -347,6 +347,7 @@ coreHelpers.ghost_script_tags = function () { coreHelpers.body_class = function (options) { /*jslint unparam:true*/ var classes = [], + post = this.post, tags = this.post && this.post.tags ? this.post.tags : this.tags || [], page = this.post && this.post.page ? this.post.page : this.page || false; @@ -366,9 +367,26 @@ coreHelpers.body_class = function (options) { classes.push('page'); } - return filters.doFilter('body_class', classes).then(function (classes) { - var classString = _.reduce(classes, function (memo, item) { return memo + ' ' + item; }, ''); - return new hbs.handlebars.SafeString(classString.trim()); + return api.settings.read('activeTheme').then(function (activeTheme) { + var paths = config().paths.availableThemes[activeTheme.value], + view; + + if (post) { + view = template.getThemeViewForPost(paths, post).split('-'); + + // If this is a page and we have a custom page template + // then we need to modify the class name we inject + // e.g. 'page-contact' is outputted as 'page-template-contact' + if (view[0] === 'page' && view.length > 1) { + view.splice(1, 0, 'template'); + classes.push(view.join('-')); + } + } + + return filters.doFilter('body_class', classes).then(function (classes) { + var classString = _.reduce(classes, function (memo, item) { return memo + ' ' + item; }, ''); + return new hbs.handlebars.SafeString(classString.trim()); + }); }); }; diff --git a/core/server/helpers/template.js b/core/server/helpers/template.js index baba6a9637..622ebab03c 100644 --- a/core/server/helpers/template.js +++ b/core/server/helpers/template.js @@ -23,4 +23,26 @@ templates.execute = function (name, context) { return new hbs.handlebars.SafeString(partial(context)); }; +// Given a theme object and a post object this will return +// which theme template page should be used. +// If given a post object that is a regular post +// it will return 'post'. +// If given a static post object it will return 'page'. +// If given a static post object and a custom page template +// exits it will return that page. +templates.getThemeViewForPost = function (themePaths, post) { + var customPageView = 'page-' + post.slug, + view = 'post'; + + if (post.page) { + if (themePaths.hasOwnProperty(customPageView + '.hbs')) { + view = customPageView; + } else if (themePaths.hasOwnProperty('page.hbs')) { + view = 'page'; + } + } + + return view; +}; + module.exports = templates; \ No newline at end of file diff --git a/core/test/unit/frontend_spec.js b/core/test/unit/frontend_spec.js index e31d519ac9..8823def0ba 100644 --- a/core/test/unit/frontend_spec.js +++ b/core/test/unit/frontend_spec.js @@ -4,7 +4,8 @@ var assert = require('assert'), should = require('should'), sinon = require('sinon'), when = require('when'), - rewire = require("rewire"), + rewire = require('rewire'), + _ = require('lodash'), // Stuff we are testing api = require('../../server/api'), @@ -258,7 +259,7 @@ describe('Frontend Controller', function () { }); describe('single', function () { - var mockStaticPost = { + var mockPosts = [{ 'status': 'published', 'id': 1, 'title': 'Test static page', @@ -266,8 +267,7 @@ describe('Frontend Controller', function () { 'markdown': 'Test static page content', 'page': 1, 'published_at': new Date('2013/12/30').getTime() - }, - mockPost = { + }, { 'status': 'published', 'id': 2, 'title': 'Test normal post', @@ -275,7 +275,15 @@ describe('Frontend Controller', function () { 'markdown': 'The test normal post content', 'page': 0, 'published_at': new Date('2014/1/2').getTime() - }, + }, { + 'status': 'published', + 'id': 3, + 'title': 'About', + 'slug': 'about', + 'markdown': 'This is the about page content', + 'page': 1, + 'published_at': new Date('2014/1/30').getTime() + }], // Helper function to prevent unit tests // from failing via timeout when they // should just immediately fail @@ -287,13 +295,7 @@ describe('Frontend Controller', function () { beforeEach(function () { sandbox.stub(api.posts, 'read', function (args) { - if (args.slug) { - return when(args.slug === mockStaticPost.slug ? mockStaticPost : mockPost); - } else if (args.id) { - return when(args.id === mockStaticPost.id ? mockStaticPost : mockPost); - } else { - return when({}); - } + return when(_.find(mockPosts, args)); }); apiSettingsStub = sandbox.stub(api.settings, 'read'); @@ -312,6 +314,7 @@ describe('Frontend Controller', function () { 'default.hbs': '/content/themes/casper/default.hbs', 'index.hbs': '/content/themes/casper/index.hbs', 'page.hbs': '/content/themes/casper/page.hbs', + 'page-about.hbs': '/content/themes/casper/page-about.hbs', 'post.hbs': '/content/themes/casper/post.hbs' } } @@ -321,6 +324,29 @@ describe('Frontend Controller', function () { describe('static pages', function () { + describe('custom page templates', function () { + beforeEach(function () { + apiSettingsStub.withArgs('permalinks').returns(when({ + value: '/:slug/' + })); + }); + + it('it will render custom page template if it exists', function (done) { + var req = { + path: '/' + mockPosts[2].slug + }, + res = { + render: function (view, context) { + assert.equal(view, 'page-' + mockPosts[2].slug); + assert.equal(context.post, mockPosts[2]); + done(); + } + }; + + frontend.single(req, res, failTest(done)); + }); + }); + describe('permalink set to slug', function () { beforeEach(function () { apiSettingsStub.withArgs('permalinks').returns(when({ @@ -330,12 +356,12 @@ describe('Frontend Controller', function () { it('will render static page via /:slug', function (done) { var req = { - path: '/' + mockStaticPost.slug + path: '/' + mockPosts[0].slug }, res = { render: function (view, context) { assert.equal(view, 'page'); - assert.equal(context.post, mockStaticPost); + assert.equal(context.post, mockPosts[0]); done(); } }; @@ -345,7 +371,7 @@ describe('Frontend Controller', function () { it('will NOT render static page via /YYY/MM/DD/:slug', function (done) { var req = { - path: '/' + ['2012/12/30', mockStaticPost.slug].join('/') + path: '/' + ['2012/12/30', mockPosts[0].slug].join('/') }, res = { render: sinon.spy() @@ -359,13 +385,13 @@ describe('Frontend Controller', function () { it('will redirect static page to admin edit page via /:slug/edit', function (done) { var req = { - path: '/' + [mockStaticPost.slug, 'edit'].join('/') + path: '/' + [mockPosts[0].slug, 'edit'].join('/') }, res = { render: sinon.spy(), redirect: function(arg) { res.render.called.should.be.false; - arg.should.eql(adminEditPagePath + mockStaticPost.id + '/'); + arg.should.eql(adminEditPagePath + mockPosts[0].id + '/'); done(); } }; @@ -375,7 +401,7 @@ describe('Frontend Controller', function () { it('will NOT redirect static page to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) { var req = { - path: '/' + ['2012/12/30', mockStaticPost.slug, 'edit'].join('/') + path: '/' + ['2012/12/30', mockPosts[0].slug, 'edit'].join('/') }, res = { render: sinon.spy(), @@ -399,12 +425,12 @@ describe('Frontend Controller', function () { it('will render static page via /:slug', function (done) { var req = { - path: '/' + mockStaticPost.slug + path: '/' + mockPosts[0].slug }, res = { render: function (view, context) { assert.equal(view, 'page'); - assert.equal(context.post, mockStaticPost); + assert.equal(context.post, mockPosts[0]); done(); } }; @@ -414,7 +440,7 @@ describe('Frontend Controller', function () { it('will NOT render static page via /YYYY/MM/DD/:slug', function (done) { var req = { - path: '/' + ['2012/12/30', mockStaticPost.slug].join('/') + path: '/' + ['2012/12/30', mockPosts[0].slug].join('/') }, res = { render: sinon.spy() @@ -428,13 +454,13 @@ describe('Frontend Controller', function () { it('will redirect static page to admin edit page via /:slug/edit', function (done) { var req = { - path: '/' + [mockStaticPost.slug, 'edit'].join('/') + path: '/' + [mockPosts[0].slug, 'edit'].join('/') }, res = { render: sinon.spy(), redirect: function (arg) { res.render.called.should.be.false; - arg.should.eql(adminEditPagePath + mockStaticPost.id + '/'); + arg.should.eql(adminEditPagePath + mockPosts[0].id + '/'); done(); } }; @@ -444,7 +470,7 @@ describe('Frontend Controller', function () { it('will NOT redirect static page to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) { var req = { - path: '/' + ['2012/12/30', mockStaticPost.slug, 'edit'].join('/') + path: '/' + ['2012/12/30', mockPosts[0].slug, 'edit'].join('/') }, res = { render: sinon.spy(), @@ -470,13 +496,13 @@ describe('Frontend Controller', function () { it('will render post via /:slug', function (done) { var req = { - path: '/' + mockPost.slug + path: '/' + mockPosts[1].slug }, res = { render: function (view, context) { assert.equal(view, 'post'); assert(context.post, 'Context object has post attribute'); - assert.equal(context.post, mockPost); + assert.equal(context.post, mockPosts[1]); done(); } }; @@ -486,7 +512,7 @@ describe('Frontend Controller', function () { it('will NOT render post via /YYYY/MM/DD/:slug', function (done) { var req = { - path: '/' + ['2012/12/30', mockPost.slug].join('/') + path: '/' + ['2012/12/30', mockPosts[1].slug].join('/') }, res = { render: sinon.spy() @@ -501,13 +527,13 @@ describe('Frontend Controller', function () { // Handle Edit append it('will redirect post to admin edit page via /:slug/edit', function (done) { var req = { - path: '/' + [mockPost.slug, 'edit'].join('/') + path: '/' + [mockPosts[1].slug, 'edit'].join('/') }, res = { render: sinon.spy(), redirect: function(arg) { res.render.called.should.be.false; - arg.should.eql(adminEditPagePath + mockPost.id + '/'); + arg.should.eql(adminEditPagePath + mockPosts[1].id + '/'); done(); } }; @@ -517,7 +543,7 @@ describe('Frontend Controller', function () { it('will NOT redirect post to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) { var req = { - path: '/' + ['2012/12/30', mockPost.slug, 'edit'].join('/') + path: '/' + ['2012/12/30', mockPosts[1].slug, 'edit'].join('/') }, res = { render: sinon.spy(), @@ -540,15 +566,15 @@ describe('Frontend Controller', function () { }); it('will render post via /YYYY/MM/DD/:slug', function (done) { - var date = moment(mockPost.published_at).format('YYYY/MM/DD'), + var date = moment(mockPosts[1].published_at).format('YYYY/MM/DD'), req = { - path: '/' + [date, mockPost.slug].join('/') + path: '/' + [date, mockPosts[1].slug].join('/') }, res = { render: function (view, context) { assert.equal(view, 'post'); assert(context.post, 'Context object has post attribute'); - assert.equal(context.post, mockPost); + assert.equal(context.post, mockPosts[1]); done(); } }; @@ -557,9 +583,9 @@ describe('Frontend Controller', function () { }); it('will NOT render post via /YYYY/MM/DD/:slug with non-matching date in url', function (done) { - var date = moment(mockPost.published_at).subtract('days', 1).format('YYYY/MM/DD'), + var date = moment(mockPosts[1].published_at).subtract('days', 1).format('YYYY/MM/DD'), req = { - path: '/' + [date, mockPost.slug].join('/') + path: '/' + [date, mockPosts[1].slug].join('/') }, res = { render: sinon.spy() @@ -573,7 +599,7 @@ describe('Frontend Controller', function () { it('will NOT render post via /:slug', function (done) { var req = { - path: '/' + mockPost.slug + path: '/' + mockPosts[1].slug }, res = { render: sinon.spy() @@ -587,15 +613,15 @@ describe('Frontend Controller', function () { // Handle Edit append it('will redirect post to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) { - var dateFormat = moment(mockPost.published_at).format('YYYY/MM/DD'), + var dateFormat = moment(mockPosts[1].published_at).format('YYYY/MM/DD'), req = { - path: '/' + [dateFormat, mockPost.slug, 'edit'].join('/') + path: '/' + [dateFormat, mockPosts[1].slug, 'edit'].join('/') }, res = { render: sinon.spy(), redirect: function (arg) { res.render.called.should.be.false; - arg.should.eql(adminEditPagePath + mockPost.id + '/'); + arg.should.eql(adminEditPagePath + mockPosts[1].id + '/'); done(); } }; @@ -605,7 +631,7 @@ describe('Frontend Controller', function () { it('will NOT redirect post to admin edit page via /:slug/edit', function (done) { var req = { - path: '/' + [mockPost.slug, 'edit'].join('/') + path: '/' + [mockPosts[1].slug, 'edit'].join('/') }, res = { render: sinon.spy(), @@ -628,15 +654,15 @@ describe('Frontend Controller', function () { }); it('will render post via /:year/:slug', function (done) { - var date = moment(mockPost.published_at).format('YYYY'), + var date = moment(mockPosts[1].published_at).format('YYYY'), req = { - path: '/' + [date, mockPost.slug].join('/') + path: '/' + [date, mockPosts[1].slug].join('/') }, res = { render: function (view, context) { assert.equal(view, 'post'); assert(context.post, 'Context object has post attribute'); - assert.equal(context.post, mockPost); + assert.equal(context.post, mockPosts[1]); done(); } }; @@ -645,9 +671,9 @@ describe('Frontend Controller', function () { }); it('will NOT render post via /YYYY/MM/DD/:slug', function (done) { - var date = moment(mockPost.published_at).format('YYYY/MM/DD'), + var date = moment(mockPosts[1].published_at).format('YYYY/MM/DD'), req = { - path: '/' + [date, mockPost.slug].join('/') + path: '/' + [date, mockPosts[1].slug].join('/') }, res = { render: sinon.spy() @@ -660,9 +686,9 @@ describe('Frontend Controller', function () { }); it('will NOT render post via /:year/slug when year does not match post year', function (done) { - var date = moment(mockPost.published_at).subtract('years', 1).format('YYYY'), + var date = moment(mockPosts[1].published_at).subtract('years', 1).format('YYYY'), req = { - path: '/' + [date, mockPost.slug].join('/') + path: '/' + [date, mockPosts[1].slug].join('/') }, res = { render: sinon.spy() @@ -676,7 +702,7 @@ describe('Frontend Controller', function () { it('will NOT render post via /:slug', function (done) { var req = { - path: '/' + mockPost.slug + path: '/' + mockPosts[1].slug }, res = { render: sinon.spy() @@ -690,15 +716,15 @@ describe('Frontend Controller', function () { // Handle Edit append it('will redirect post to admin edit page via /:year/:slug/edit', function (done) { - var date = moment(mockPost.published_at).format('YYYY'), + var date = moment(mockPosts[1].published_at).format('YYYY'), req = { - path: '/' + [date, mockPost.slug, 'edit'].join('/') + path: '/' + [date, mockPosts[1].slug, 'edit'].join('/') }, res = { render: sinon.spy(), redirect: function (arg) { res.render.called.should.be.false; - arg.should.eql(adminEditPagePath + mockPost.id + '/'); + arg.should.eql(adminEditPagePath + mockPosts[1].id + '/'); done(); } }; @@ -708,7 +734,7 @@ describe('Frontend Controller', function () { it('will NOT redirect post to admin edit page /:slug/edit', function (done) { var req = { - path: '/' + [mockPost.slug, 'edit'].join('/') + path: '/' + [mockPosts[1].slug, 'edit'].join('/') }, res = { render: sinon.spy(), diff --git a/core/test/unit/server_helpers_index_spec.js b/core/test/unit/server_helpers_index_spec.js index abb78ba6c9..ea535803b3 100644 --- a/core/test/unit/server_helpers_index_spec.js +++ b/core/test/unit/server_helpers_index_spec.js @@ -20,6 +20,7 @@ describe('Core Helpers', function () { var sandbox, apiStub, + configStub, overrideConfig = function (newConfig) { helpers.__set__('config', function() { return newConfig; @@ -35,13 +36,28 @@ describe('Core Helpers', function () { }); config = helpers.__get__('config'); - config.theme = sandbox.stub(config, 'theme', function () { - return { - title: 'Ghost', - description: 'Just a blogging platform.', - url: 'http://testurl.com' - }; + configStub = sandbox.stub().returns({ + 'paths': { + 'subdir': '', + 'availableThemes': { + 'casper': { + 'assets': null, + 'default.hbs': '/content/themes/casper/default.hbs', + 'index.hbs': '/content/themes/casper/index.hbs', + 'page.hbs': '/content/themes/casper/page.hbs', + 'page-about.hbs': '/content/themes/casper/page-about.hbs', + 'post.hbs': '/content/themes/casper/post.hbs' + } + } + } }); + _.extend(configStub, config); + configStub.theme = sandbox.stub().returns({ + title: 'Ghost', + description: 'Just a blogging platform.', + url: 'http://testurl.com' + }); + helpers.__set__('config', configStub); helpers.loadCoreHelpers(adminHbs); // Load template helpers in handlebars @@ -310,6 +326,22 @@ describe('Core Helpers', function () { done(); }).then(null, done); }); + + it('can render class for static page with custom template', function (done) { + helpers.body_class.call({ + relativeUrl: '/about', + post: { + page: true, + slug: 'about' + + } + }).then(function (rendered) { + should.exist(rendered); + rendered.string.should.equal('post-template page page-template-about'); + + done(); + }).then(null, done); + }); }); describe('post_class Helper', function () { diff --git a/core/test/unit/server_helpers_template_spec.js b/core/test/unit/server_helpers_template_spec.js index 7ef6179e27..fddc5cf5c5 100644 --- a/core/test/unit/server_helpers_template_spec.js +++ b/core/test/unit/server_helpers_template_spec.js @@ -22,4 +22,40 @@ describe('Helpers Template', function () { should.exist(safeString); safeString.should.have.property('string').and.equal('

Hello world

'); }); + + describe('getThemeViewForPost', function () { + var themePaths = { + 'assets': null, + 'default.hbs': '/content/themes/casper/default.hbs', + 'index.hbs': '/content/themes/casper/index.hbs', + 'page.hbs': '/content/themes/casper/page.hbs', + 'page-about.hbs': '/content/themes/casper/page-about.hbs', + 'post.hbs': '/content/themes/casper/post.hbs' + }, + posts = [{ + page: 1, + slug: 'about' + }, { + page: 1, + slug: 'contact' + }, { + page: 0, + slug: 'test-post' + }]; + + it('will return correct view for a post', function () { + var view = template.getThemeViewForPost(themePaths, posts[0]); + view.should.exist; + view.should.eql('page-about'); + + view = template.getThemeViewForPost(themePaths, posts[1]); + view.should.exist; + view.should.eql('page'); + + view = template.getThemeViewForPost(themePaths, posts[2]); + view.should.exist; + view.should.eql('post'); + }); + + }); }); \ No newline at end of file From 6e1d5e8e0d72f142fe20c92a8934694946d203a2 Mon Sep 17 00:00:00 2001 From: Kyle Nunery Date: Sun, 23 Feb 2014 16:16:45 -0600 Subject: [PATCH 03/27] Fixes client side bio character counter. closes #1432 --- core/client/assets/vendor/countable.js | 18 +++++++------- core/test/functional/admin/settings_test.js | 26 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/core/client/assets/vendor/countable.js b/core/client/assets/vendor/countable.js index 155f32aa97..524c4ff8d4 100644 --- a/core/client/assets/vendor/countable.js +++ b/core/client/assets/vendor/countable.js @@ -3,7 +3,7 @@ * counting on an HTML element. * * @author Sacha Schmid () - * @version 2.0.0 + * @version 2.0.2 * @license MIT * @see */ @@ -124,9 +124,11 @@ * `_extendDefaults` is a function to extend a set of default options with the * ones given in the function call. Available options are described below. * - * {Boolean} hardReturns Use two returns to seperate a paragraph instead of - * one. - * {Boolean} stripTags Strip HTML tags before counting the values. + * {Boolean} hardReturns Use two returns to seperate a paragraph instead + * of one. + * {Boolean} stripTags Strip HTML tags before counting the values. + * {Boolean} ignoreReturns Ignore returns when calculating the `all` + * property. * * @private * @@ -138,7 +140,7 @@ */ function _extendDefaults (options) { - var defaults = { hardReturns: false, stripTags: false } + var defaults = { hardReturns: false, stripTags: false, ignoreReturns: false } for (var prop in options) { if (defaults.hasOwnProperty(prop)) defaults[prop] = options[prop] @@ -163,7 +165,7 @@ function _count (element, options) { var original = 'value' in element ? element.value : element.innerText || element.textContent, - temp, trimmed + trimmed /** * The initial implementation to allow for HTML tags stripping was created @@ -187,7 +189,7 @@ paragraphs: trimmed ? (trimmed.match(options.hardReturns ? /\n{2,}/g : /\n+/g) || []).length + 1 : 0, words: trimmed ? (trimmed.replace(/['";:,.?¿\-!¡]+/g, '').match(/\S+/g) || []).length : 0, characters: trimmed ? _decode(trimmed.replace(/\s/g, '')).length : 0, - all: _decode(original.replace(/[\n\r]/g, '')).length + all: _decode(options.ignoreReturns ? original.replace(/[\n\r]/g, '') : original).length } } @@ -374,4 +376,4 @@ } else { global.Countable = Countable } -}(this)) \ No newline at end of file +}(this)) diff --git a/core/test/functional/admin/settings_test.js b/core/test/functional/admin/settings_test.js index 0882aef54a..cc4e50065c 100644 --- a/core/test/functional/admin/settings_test.js +++ b/core/test/functional/admin/settings_test.js @@ -153,4 +153,30 @@ CasperTest.begin("User settings screen validates email", 6, function suite(test) }, function onTimeout() { test.assert(false, 'No success notification :('); }); +}); + +CasperTest.begin("User settings screen shows remaining characters for Bio properly", 4, function suite(test) { + + function getRemainingBioCharacterCount() { + return casper.getHTML('.word-count'); + } + + casper.thenOpen(url + "ghost/settings/user/", function testTitleAndUrl() { + test.assertTitle("Ghost Admin", "Ghost admin has no title"); + test.assertUrlMatch(/ghost\/settings\/user\/$/, "Ghost doesn't require login this time"); + }); + + casper.then(function checkCharacterCount() { + test.assert(getRemainingBioCharacterCount() === '200', 'Bio remaining characters is 200'); + }); + + casper.then(function setBioToValid() { + casper.fillSelectors('.user-profile', { + '#user-bio': 'asdf\n' // 5 characters + }, false); + }); + + casper.then(function checkCharacterCount() { + test.assert(getRemainingBioCharacterCount() === '195', 'Bio remaining characters is 195'); + }); }); \ No newline at end of file From 917eca3244ca533dc6fa9b10404b92d8fd4272ce Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Mon, 24 Feb 2014 09:28:07 -0700 Subject: [PATCH 04/27] Change fallback from address to webmaster@[blog.url] This change is needed because the previous default of the user's email address is too often mismatched against the site domain, triggering spam filters. Fixes #2145 - added `fromAddress()` to GhostMailer to handle this logic - added unit tests to `mail_spec.js` --- core/server/mail.js | 21 ++++++++++++++++++--- core/test/unit/mail_spec.js | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/core/server/mail.js b/core/server/mail.js index 627fece2a8..80263d7f3c 100644 --- a/core/server/mail.js +++ b/core/server/mail.js @@ -82,6 +82,22 @@ GhostMailer.prototype.emailDisabled = function () { this.transport = null; }; +GhostMailer.prototype.fromAddress = function () { + var from = config().mail && config().mail.fromaddress, + domain; + + if (!from) { + // Extract the domain name from url set in config.js + domain = config().url.match(new RegExp("^https?://([^/:?#]+)(?:[/:?#]|$)", "i")); + domain = domain && domain[1]; + + // Default to webmaster@[blog.url] + from = 'webmaster@' + domain; + } + + return from; +}; + // Sends an e-mail message enforcing `to` (blog owner) and `from` fields GhostMailer.prototype.send = function (message) { var self = this; @@ -94,11 +110,10 @@ GhostMailer.prototype.send = function (message) { } return api.settings.read('email').then(function (email) { - var from = (config().mail && config().mail.fromaddress) || email.value, - to = message.to || email.value; + var to = message.to || email.value; message = _.extend(message, { - from: from, + from: self.fromAddress(), to: to, generateTextFromHTML: true }); diff --git a/core/test/unit/mail_spec.js b/core/test/unit/mail_spec.js index ed3d2107b6..e815fb3faa 100644 --- a/core/test/unit/mail_spec.js +++ b/core/test/unit/mail_spec.js @@ -167,4 +167,26 @@ describe("Mail", function () { done(); }); }); + + it('should use from address as configured in config.js', function (done) { + overrideConfig({mail:{fromaddress: 'static@example.com'}}); + mailer.fromAddress().should.equal('static@example.com'); + done(); + }); + + it('should fall back to webmaster@[blog.url] as from address', function (done) { + // Standard domain + overrideConfig({url: 'http://default.com', mail:{fromaddress: null}}); + mailer.fromAddress().should.equal('webmaster@default.com'); + + // Trailing slash + overrideConfig({url: 'http://default.com/', mail:{}}); + mailer.fromAddress().should.equal('webmaster@default.com'); + + // Strip Port + overrideConfig({url: 'http://default.com:2368/', mail:{}}); + mailer.fromAddress().should.equal('webmaster@default.com'); + + done(); + }); }); From ae06239834d8394fbf341cb8fe205126888045e6 Mon Sep 17 00:00:00 2001 From: Sebastian Gierlinger Date: Tue, 25 Feb 2014 20:15:32 +0100 Subject: [PATCH 05/27] Fix problems from importing 0.4.0 file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes #2244 - added mysql ‚true’/‚false‘ values as ‚0‘/‚1‘ - removed all core settings from import --- core/server/data/import/000.js | 6 +++--- core/server/update-check.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/server/data/import/000.js b/core/server/data/import/000.js index 5346454f15..8a5eb63aff 100644 --- a/core/server/data/import/000.js +++ b/core/server/data/import/000.js @@ -112,13 +112,13 @@ function importUsers(ops, tableData, transaction) { function importSettings(ops, tableData, transaction) { // for settings we need to update individual settings, and insert any missing ones - // settings we MUST NOT update are the databaseVersion, dbHash, and activeTheme + // settings we MUST NOT update are 'core' and 'theme' settings // as all of these will cause side effects which don't make sense for an import + var blackList = ['core', 'theme']; - var blackList = ['databaseVersion', 'dbHash', 'activeTheme']; tableData = stripProperties(['id'], tableData); tableData = _.filter(tableData, function (data) { - return blackList.indexOf(data.key) === -1; + return blackList.indexOf(data.type) === -1; }); ops.push(models.Settings.edit(tableData, transaction) diff --git a/core/server/update-check.js b/core/server/update-check.js index 13c3c4a5b3..63e9729bbc 100644 --- a/core/server/update-check.js +++ b/core/server/update-check.js @@ -192,7 +192,7 @@ function showUpdateNotification() { // Version 0.4 used boolean to indicate the need for an update. This special case is // translated to the version string. // TODO: remove in future version. - if (display.value === 'false' || display.value === 'true') { + if (display.value === 'false' || display.value === 'true' || display.value === '1' || display.value === '0') { display.value = '0.4.0'; } From 12f8f990887527b35f925b525b8917aecdcbd03c Mon Sep 17 00:00:00 2001 From: Fabian Becker Date: Sat, 8 Feb 2014 21:12:04 +0100 Subject: [PATCH 06/27] Implements the #has Block helper closes #2115 - Added new #has block helper - Added several tests for #has helper --- core/server/helpers/index.js | 33 ++++++++++++ core/test/unit/server_helpers_index_spec.js | 58 +++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/core/server/helpers/index.js b/core/server/helpers/index.js index f34c9b2b16..73d1d63641 100644 --- a/core/server/helpers/index.js +++ b/core/server/helpers/index.js @@ -572,6 +572,37 @@ coreHelpers.foreach = function (context, options) { return ret; }; +// ### Has Helper +// `{{#has tag="video, music"}}` +// Checks whether a post has at least one of the tags +coreHelpers.has = function (options) { + var tags = _.pluck(this.tags, 'name'), + tagList = options && options.hash ? options.hash.tag : false; + + function evaluateTagList(expr, tags) { + return expr.split(',').map(function (v) { + return v.trim(); + }).reduce(function (p, c) { + return p || (_.findIndex(tags, function (item) { + // Escape regex special characters + item = item.replace(/[\-\/\\\^$*+?.()|\[\]{}]/g, '\\$&'); + item = new RegExp(item, 'i'); + return item.test(c); + }) !== -1); + }, false); + } + + if (!tagList) { + errors.logWarn("Invalid or no attribute given to has helper"); + return; + } + + if (tagList && evaluateTagList(tagList, tags)) { + return options.fn(this); + } + return options.inverse(this); +}; + // ### Pagination Helper // `{{pagination}}` // Outputs previous and next buttons, along with info about the current page @@ -702,6 +733,8 @@ registerHelpers = function (adminHbs, assetHash) { registerThemeHelper('foreach', coreHelpers.foreach); + registerThemeHelper('has', coreHelpers.has); + registerThemeHelper('page_url', coreHelpers.page_url); registerThemeHelper('pageUrl', coreHelpers.pageUrl); diff --git a/core/test/unit/server_helpers_index_spec.js b/core/test/unit/server_helpers_index_spec.js index abb78ba6c9..5e64ef5fdc 100644 --- a/core/test/unit/server_helpers_index_spec.js +++ b/core/test/unit/server_helpers_index_spec.js @@ -427,6 +427,64 @@ describe('Core Helpers', function () { }); }); + describe('has Block Helper', function () { + it('has loaded has block helper', function () { + should.exist(handlebars.helpers.has); + }); + + it('should handle tag list that validates true', function () { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.has.call( + {tags: [{ name: 'foo'}, { name: 'bar'}, { name: 'baz'}]}, + {hash: { tag: 'invalid, bar, wat'}, fn: fn, inverse: inverse} + ); + + fn.called.should.be.true; + inverse.called.should.be.false; + }); + + it('should handle tags with case-insensitivity', function () { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.has.call( + {tags: [{ name: 'ghost'}]}, + {hash: { tag: 'GhoSt'}, fn: fn, inverse: inverse} + ); + + fn.called.should.be.true; + inverse.called.should.be.false; + }); + + it('should handle tag list that validates false', function () { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.has.call( + {tags: [{ name: 'foo'}, { name: 'bar'}, { name: 'baz'}]}, + {hash: { tag: 'much, such, wow'}, fn: fn, inverse: inverse} + ); + + fn.called.should.be.false; + inverse.called.should.be.true; + }); + + it('should not do anything when an invalid attribute is given', function () { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.has.call( + {tags: [{ name: 'foo'}, { name: 'bar'}, { name: 'baz'}]}, + {hash: { invalid: 'nonsense'}, fn: fn, inverse: inverse} + ); + + fn.called.should.be.false; + inverse.called.should.be.false; + }); + }); + describe('url Helper', function () { it('has loaded url helper', function () { should.exist(handlebars.helpers.url); From 67611045e7cb6f90fb3015046adaac8737b20b69 Mon Sep 17 00:00:00 2001 From: Sebastian Gierlinger Date: Thu, 27 Feb 2014 16:48:38 +0100 Subject: [PATCH 07/27] Remove res.redirect from db.exportContent closes #1654 - added frontend route /ghost/export/ - removed request handling from API --- core/server/api/db.js | 30 ++++-------------------------- core/server/controllers/admin.js | 22 ++++++++++++++++++++++ core/server/routes/admin.js | 2 ++ core/server/routes/api.js | 2 +- core/server/views/debug.hbs | 2 +- 5 files changed, 30 insertions(+), 28 deletions(-) diff --git a/core/server/api/db.js b/core/server/api/db.js index ef717d7642..3d1bb83d09 100644 --- a/core/server/api/db.js +++ b/core/server/api/db.js @@ -16,32 +16,10 @@ api.notifications = require('./notifications'); api.settings = require('./settings'); db = { - 'exportContent': function (req, res) { - /*jslint unparam:true*/ - return dataExport().then(function (exportedData) { - // Save the exported data to the file system for download - var fileName = path.join(config().paths.exportPath, 'exported-' + (new Date().getTime()) + '.json'); - - return nodefn.call(fs.writeFile, fileName, JSON.stringify(exportedData)).then(function () { - return when(fileName); - }); - }).then(function (exportedFilePath) { - // Send the exported data file - res.download(exportedFilePath, 'GhostData.json'); - }).otherwise(function (error) { - // Notify of an error if it occurs - return api.notifications.browse().then(function (notifications) { - var notification = { - type: 'error', - message: error.message || error, - status: 'persistent', - id: 'per-' + (notifications.length + 1) - }; - - return api.notifications.add(notification).then(function () { - res.redirect(config().paths.debugPath); - }); - }); + 'exportContent': function () { + // Export data, otherwise send error 500 + return dataExport().otherwise(function (error) { + return when.reject({errorCode: 500, message: error.message || error}); }); }, 'importContent': function (options) { diff --git a/core/server/controllers/admin.js b/core/server/controllers/admin.js index e8f2d2f19c..bee933dedc 100644 --- a/core/server/controllers/admin.js +++ b/core/server/controllers/admin.js @@ -111,6 +111,28 @@ adminControllers = { bodyClass: 'settings', adminNav: setSelected(adminNavbar, 'settings') }); + }, + // frontend route for downloading a file + exportContent: function (req, res) { + /*jslint unparam:true*/ + api.db.exportContent().then(function (exportData) { + // send a file to the client + res.set('Content-Disposition', 'attachment; filename="GhostData.json"'); + res.json(exportData); + }).otherwise(function (err) { + var notification = { + type: 'error', + message: 'Your export file could not be generated.', + status: 'persistent', + id: 'errorexport' + }; + + errors.logError(err, 'admin.js', "Your export file could not be generated."); + + return api.notifications.add(notification).then(function () { + res.redirect(config().paths.subdir + '/ghost/debug'); + }); + }); } }, // Route: upload diff --git a/core/server/routes/admin.js b/core/server/routes/admin.js index f9fe4d8470..cb955c16bf 100644 --- a/core/server/routes/admin.js +++ b/core/server/routes/admin.js @@ -43,6 +43,8 @@ module.exports = function (server) { server.get('/ghost/settings*', admin.settings); server.get('/ghost/debug/', admin.debug.index); + server.get('/ghost/export/', admin.debug.exportContent); + server.post('/ghost/upload/', middleware.busboy, admin.upload); // redirect to /ghost and let that do the authentication to prevent redirects to /ghost//admin etc. diff --git a/core/server/routes/api.js b/core/server/routes/api.js index ed2742fa3e..b5b3cb8bb8 100644 --- a/core/server/routes/api.js +++ b/core/server/routes/api.js @@ -24,7 +24,7 @@ module.exports = function (server) { server.del('/ghost/api/v0.1/notifications/:id', api.requestHandler(api.notifications.destroy)); server.post('/ghost/api/v0.1/notifications/', api.requestHandler(api.notifications.add)); // #### Import/Export - server.get('/ghost/api/v0.1/db/', api.db.exportContent); + server.get('/ghost/api/v0.1/db/', api.requestHandler(api.db.exportContent)); server.post('/ghost/api/v0.1/db/', middleware.busboy, api.requestHandler(api.db.importContent)); server.del('/ghost/api/v0.1/db/', api.requestHandler(api.db.deleteAllContent)); }; \ No newline at end of file diff --git a/core/server/views/debug.hbs b/core/server/views/debug.hbs index 8116277eaf..a960018e47 100644 --- a/core/server/views/debug.hbs +++ b/core/server/views/debug.hbs @@ -20,7 +20,7 @@
- Export + Export

Export the blog settings and data.

From b49f10c33d101fffc697615cdd0547dc17ef0ec1 Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 25 Feb 2014 10:18:33 +0000 Subject: [PATCH 08/27] Add support for typographically-correct punctuation Closes #1795 - Added typography.js Showdown extension - Updated RSS test to support new typographic quotes --- Gruntfile.js | 2 + core/client/views/editor.js | 2 +- core/server/models/post.js | 5 +- .../vendor/showdown/extensions/typography.js | 114 ++++++++++++++++++ core/test/functional/frontend/feed_test.js | 2 +- 5 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 core/shared/vendor/showdown/extensions/typography.js diff --git a/Gruntfile.js b/Gruntfile.js index 64ef36da8f..d1c60be155 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -382,6 +382,7 @@ var path = require('path'), 'core/client/assets/vendor/codemirror/mode/gfm/gfm.js', 'core/client/assets/vendor/showdown/showdown.js', 'core/client/assets/vendor/showdown/extensions/ghostdown.js', + 'core/shared/vendor/showdown/extensions/typography.js', 'core/shared/vendor/showdown/extensions/github.js', 'core/client/assets/vendor/shortcuts.js', 'core/client/assets/vendor/validator-client.js', @@ -437,6 +438,7 @@ var path = require('path'), 'core/client/assets/vendor/codemirror/mode/gfm/gfm.js', 'core/client/assets/vendor/showdown/showdown.js', 'core/client/assets/vendor/showdown/extensions/ghostdown.js', + 'core/shared/vendor/showdown/extensions/typography.js', 'core/shared/vendor/showdown/extensions/github.js', 'core/client/assets/vendor/shortcuts.js', 'core/client/assets/vendor/validator-client.js', diff --git a/core/client/views/editor.js b/core/client/views/editor.js index 4ebd2dbda1..fcccbd9468 100644 --- a/core/client/views/editor.js +++ b/core/client/views/editor.js @@ -449,7 +449,7 @@ initMarkdown: function () { var self = this; - this.converter = new Showdown.converter({extensions: ['ghostdown', 'github']}); + this.converter = new Showdown.converter({extensions: ['typography', 'ghostdown', 'github']}); this.editor = CodeMirror.fromTextArea(document.getElementById('entry-markdown'), { mode: 'gfm', tabMode: 'indent', diff --git a/core/server/models/post.js b/core/server/models/post.js index 66de0722c4..34b2ad48b3 100644 --- a/core/server/models/post.js +++ b/core/server/models/post.js @@ -4,7 +4,8 @@ var _ = require('lodash'), errors = require('../errorHandling'), Showdown = require('showdown'), github = require('../../shared/vendor/showdown/extensions/github'), - converter = new Showdown.converter({extensions: [github]}), + typography = require('../../shared/vendor/showdown/extensions/typography'), + converter = new Showdown.converter({extensions: [typography, github]}), User = require('./user').User, Tag = require('./tag').Tag, Tags = require('./tag').Tags, @@ -453,4 +454,4 @@ Posts = ghostBookshelf.Collection.extend({ module.exports = { Post: Post, Posts: Posts -}; \ No newline at end of file +}; diff --git a/core/shared/vendor/showdown/extensions/typography.js b/core/shared/vendor/showdown/extensions/typography.js new file mode 100644 index 0000000000..998aba1e70 --- /dev/null +++ b/core/shared/vendor/showdown/extensions/typography.js @@ -0,0 +1,114 @@ +/*global module */ +// +// Replaces straight quotes with curly ones, -- and --- with en dash and em +// dash respectively, and ... with horizontal ellipses. +// + +(function () { + var typography = function () { + return [ + { + type: "lang", + filter: function (text) { + var fCodeblocks = {}, nCodeblocks = {}, iCodeblocks = {}, + e = { + endash: '\u2009\u2013\u2009', // U+2009 = thin space + emdash: '\u2014', + lsquo: '\u2018', + rsquo: '\u2019', + ldquo: '\u201c', + rdquo: '\u201d', + hellip: '\u2026' + }, + + i; + + // Extract fenced code blocks. + i = -1; + text = text.replace(/```((?:.|\n)+?)```/g, + function (match, code) { + i += 1; + fCodeblocks[i] = "```" + code + "```"; + return "{typog-fcb-" + i + "}"; + }); + + // Extract indented code blocks. + i = -1; + text = text.replace(/((\n+([ ]{4}|\t).+)+)/g, + function (match, code) { + i += 1; + nCodeblocks[i] = " " + code; + return "{typog-ncb-" + i + "}"; + }); + + // Extract inline code blocks + i = -1; + text = text.replace(/`(.+)`/g, function (match, code) { + i += 1; + iCodeblocks[i] = "`" + code + "`"; + return "{typog-icb-" + i + "}"; + }); + + // Perform typographic symbol replacement. + + // Double quotes. There might be a reason this doesn't use + // the same \b matching style as the single quotes, but I + // can't remember what it is :( + text = text. + // Opening quotes + replace(/"([\w'])/g, e.ldquo + "$1"). + // All the rest + replace(/"/g, e.rdquo); + + // Single quotes/apostrophes + text = text. + // Apostrophes first + replace(/\b'\b/g, e.rsquo). + // Opening quotes + replace(/'\b/g, e.lsquo). + // All the rest + replace(/'/g, e.rsquo); + + // Dashes + text = text. + // Don't replace lines containing only hyphens + replace(/^-+$/gm, "{typog-hr}"). + replace(/---/g, e.emdash). + replace(/ -- /g, e.endash). + replace(/{typog-hr}/g, "----"); + + // Ellipses. + text = text.replace(/\.{3}/g, e.hellip); + + + // Restore fenced code blocks. + text = text.replace(/{typog-fcb-([0-9]+)}/g, function (x, y) { + return fCodeblocks[y]; + }); + + // Restore indented code blocks. + text = text.replace(/{typog-ncb-([0-9]+)}/g, function (x, y) { + return nCodeblocks[y]; + }); + + // Restore inline code blocks. + text = text.replace(/{typog-icb-([0-9]+)}/g, function (x, y) { + return iCodeblocks[y]; + }); + + return text; + } + } + ]; + }; + + // Client-side export + if (typeof window !== 'undefined' && window.Showdown && window.Showdown.extensions) { + window.Showdown.extensions.typography = typography; + } + // Server-side export + if (typeof module !== 'undefined') { + module.exports = typography; + } +}()); + diff --git a/core/test/functional/frontend/feed_test.js b/core/test/functional/frontend/feed_test.js index 791d14e83a..439765dc69 100644 --- a/core/test/functional/frontend/feed_test.js +++ b/core/test/functional/frontend/feed_test.js @@ -10,7 +10,7 @@ CasperTest.begin('Ensure that RSS is available', 11, function suite(test) { siteDescription = '', siteUrl = 'http://127.0.0.1:2369/', postTitle = '', - postStart = 'You\'re live!', + postStart = 'You’re live!', postEnd = 'you think :)

]]>
', postLink = 'http://127.0.0.1:2369/welcome-to-ghost/', postCreator = ''; From 667888aeb359920cd4937567da20d87dcf3267da Mon Sep 17 00:00:00 2001 From: Gabor Javorszky Date: Sun, 23 Feb 2014 12:32:35 +0000 Subject: [PATCH 09/27] Implements Initial lifecycle and App UI start Closes #2083 * Added hbs template for apps listing * Added settings to read the activeApps * Added viewcontrol to activate / deactivate apps * Added API handler to store activeApps (by `name` in the `package.json` file) * On button click it turns the button into "Working" and changes class to `button` (grey one) * On success, rerenders the pane, adds success notification about apps being saved * On error, rerenders the pane, adds error notification with error message Missing: * tests: couldn't figure out how to add mock apps with mock package.json data * actually registering, etc, re #2140 * icon from the sidebar --- core/client/models/settings.js | 2 +- core/client/tpl/settings/apps.hbs | 15 ++++ core/client/tpl/settings/sidebar.hbs | 1 + core/client/views/settings.js | 66 ++++++++++++++++ core/server/api/settings.js | 85 +++++++++++++++------ core/server/controllers/admin.js | 2 +- core/test/functional/admin/settings_test.js | 5 +- core/test/utils/api.js | 2 +- 8 files changed, 149 insertions(+), 29 deletions(-) create mode 100644 core/client/tpl/settings/apps.hbs diff --git a/core/client/models/settings.js b/core/client/models/settings.js index b57193f35c..b6a58a177e 100644 --- a/core/client/models/settings.js +++ b/core/client/models/settings.js @@ -3,7 +3,7 @@ 'use strict'; //id:0 is used to issue PUT requests Ghost.Models.Settings = Ghost.ProgressModel.extend({ - url: Ghost.paths.apiRoot + '/settings/?type=blog,theme', + url: Ghost.paths.apiRoot + '/settings/?type=blog,theme,app', id: '0' }); diff --git a/core/client/tpl/settings/apps.hbs b/core/client/tpl/settings/apps.hbs new file mode 100644 index 0000000000..e584081688 --- /dev/null +++ b/core/client/tpl/settings/apps.hbs @@ -0,0 +1,15 @@ +
+ +

Apps

+
+ +
+
    + {{#each availableApps}} +
  • + {{#if package}}{{package.name}} - {{package.version}}{{else}}{{name}} - package.json missing :({{/if}} + +
  • + {{/each}} +
+
\ No newline at end of file diff --git a/core/client/tpl/settings/sidebar.hbs b/core/client/tpl/settings/sidebar.hbs index 246d0882c5..ad8295818b 100644 --- a/core/client/tpl/settings/sidebar.hbs +++ b/core/client/tpl/settings/sidebar.hbs @@ -5,5 +5,6 @@ \ No newline at end of file diff --git a/core/client/views/settings.js b/core/client/views/settings.js index 24bf32bfa8..9283924307 100644 --- a/core/client/views/settings.js +++ b/core/client/views/settings.js @@ -446,4 +446,70 @@ } }); + // ### Apps page + Settings.apps = Settings.Pane.extend({ + id: "apps", + + events: { + 'click .js-button-activate': 'activateApp', + 'click .js-button-deactivate': 'deactivateApp' + }, + + beforeRender: function () { + this.availableApps = this.model.toJSON().availableApps; + }, + + activateApp: function (event) { + var button = $(event.currentTarget); + + button.removeClass('button-add').addClass('button js-button-active').text('Working'); + + this.saveStates(); + }, + + deactivateApp: function (event) { + var button = $(event.currentTarget); + + button.removeClass('button-delete js-button-active').addClass('button').text('Working'); + + this.saveStates(); + }, + + saveStates: function () { + var activeButtons = this.$el.find('.js-apps .js-button-active'), + toSave = [], + self = this; + + _.each(activeButtons, function (app) { + toSave.push($(app).data('app')); + }); + + this.model.save({ + activeApps: JSON.stringify(toSave) + }, { + success: this.saveSuccess, + error: this.saveError + }).then(function () { self.render(); }); + }, + + saveSuccess: function () { + Ghost.notifications.addItem({ + type: 'success', + message: 'Active applications updated.', + status: 'passive', + id: 'success-1100' + }); + }, + + saveError: function (xhr) { + Ghost.notifications.addItem({ + type: 'error', + message: Ghost.Views.Utils.getRequestErrorMessage(xhr), + status: 'passive' + }); + }, + + templateName: 'settings/apps' + }); + }()); diff --git a/core/server/api/settings.js b/core/server/api/settings.js index c77313de59..f74d67d54f 100644 --- a/core/server/api/settings.js +++ b/core/server/api/settings.js @@ -9,6 +9,7 @@ var _ = require('lodash'), settingsFilter, updateSettingsCache, readSettingsResult, + filterPaths, // Holds cached settings settingsCache = {}; @@ -78,36 +79,69 @@ readSettingsResult = function (result) { } })).then(function () { return when(config().paths.availableThemes).then(function (themes) { - var themeKeys = Object.keys(themes), - res = [], - i, - item; - for (i = 0; i < themeKeys.length; i += 1) { - //do not include hidden files or _messages - if (themeKeys[i].indexOf('.') !== 0 && themeKeys[i] !== '_messages') { - item = {}; - item.name = themeKeys[i]; - if (themes[themeKeys[i]].hasOwnProperty('package.json')) { - item.package = themes[themeKeys[i]]['package.json']; - } else { - item.package = false; - } - //data about files currently not used - //item.details = themes[themeKeys[i]]; - if (themeKeys[i] === settings.activeTheme.value) { - item.active = true; - } - res.push(item); - } - } - settings.availableThemes = {}; - settings.availableThemes.value = res; - settings.availableThemes.type = 'theme'; + var res = filterPaths(themes, settings.activeTheme.value); + settings.availableThemes = { + value: res, + type: 'theme' + }; + return settings; + }); + }).then(function () { + return when(config().paths.availableApps).then(function (apps) { + var res = filterPaths(apps, JSON.parse(settings.activeApps.value)); + settings.availableApps = { + value: res, + type: 'app' + }; return settings; }); }); }; +/** + * Normalizes paths read by require-tree so that the apps and themes modules can use them. + * Creates an empty array (res), and populates it with useful info about the read packages + * like name, whether they're active (comparison with the second argument), and if they + * have a package.json, that, otherwise false + * @param object paths as returned by require-tree() + * @param array/string active as read from the settings object + * @return array of objects with useful info about + * apps / themes + */ +filterPaths = function (paths, active) { + var pathKeys = Object.keys(paths), + res = [], + item; + + // turn active into an array (so themes and apps can be checked the same) + if (!Array.isArray(active)) { + active = [active]; + } + + _.each(pathKeys, function (key) { + //do not include hidden files or _messages + if (key.indexOf('.') !== 0 + && key !== '_messages' + && key !== 'README.md' + ) { + item = { + name: key + }; + if (paths[key].hasOwnProperty('package.json')) { + item.package = paths[key]['package.json']; + } else { + item.package = false; + } + + if (_.indexOf(active, key) !== -1) { + item.active = true; + } + res.push(item); + } + }); + return res; +}; + settings = { // #### Browse @@ -153,6 +187,7 @@ settings = { var type = key.type; delete key.type; delete key.availableThemes; + delete key.availableApps; key = settingsCollection(key); return dataProvider.Settings.edit(key).then(function (result) { diff --git a/core/server/controllers/admin.js b/core/server/controllers/admin.js index e8f2d2f19c..4f85b2a61d 100644 --- a/core/server/controllers/admin.js +++ b/core/server/controllers/admin.js @@ -89,7 +89,7 @@ adminControllers = { // Method: GET 'settings': function (req, res, next) { // TODO: Centralise list/enumeration of settings panes, so we don't run into trouble in future. - var allowedSections = ['', 'general', 'user'], + var allowedSections = ['', 'general', 'user', 'app'], section = req.url.replace(/(^\/ghost\/settings[\/]*|\/$)/ig, ''); if (allowedSections.indexOf(section) < 0) { diff --git a/core/test/functional/admin/settings_test.js b/core/test/functional/admin/settings_test.js index cc4e50065c..1b3fb0e55a 100644 --- a/core/test/functional/admin/settings_test.js +++ b/core/test/functional/admin/settings_test.js @@ -1,6 +1,6 @@ /*globals casper, __utils__, url */ -CasperTest.begin("Settings screen is correct", 15, function suite(test) { +CasperTest.begin("Settings screen is correct", 18, function suite(test) { casper.thenOpen(url + "ghost/settings/", function testTitleAndUrl() { test.assertTitle("Ghost Admin", "Ghost admin has no title"); test.assertUrlMatch(/ghost\/settings\/general\/$/, "Ghost doesn't require login this time"); @@ -10,6 +10,9 @@ CasperTest.begin("Settings screen is correct", 15, function suite(test) { test.assertExists(".wrapper", "Settings main view is present"); test.assertExists(".settings-sidebar", "Settings sidebar view is present"); test.assertExists(".settings-menu", "Settings menu is present"); + test.assertExists(".settings-menu .general", "General tab is present"); + test.assertExists(".settings-menu .users", "Users tab is present"); + test.assertExists(".settings-menu .apps", "Apps is present"); test.assertExists(".wrapper", "Settings main view is present"); test.assertExists(".settings-content", "Settings content view is present"); test.assertEval(function testGeneralIsActive() { diff --git a/core/test/utils/api.js b/core/test/utils/api.js index e55b3d30bf..92a21c3e89 100644 --- a/core/test/utils/api.js +++ b/core/test/utils/api.js @@ -12,7 +12,7 @@ var _ = require('lodash'), // TODO: remove databaseVersion, dbHash settings: ['databaseVersion', 'dbHash', 'title', 'description', 'email', 'logo', 'cover', 'defaultLang', "permalinks", 'postsPerPage', 'forceI18n', 'activeTheme', 'activeApps', 'installedApps', - 'availableThemes', 'nextUpdateCheck', 'displayUpdateNotification'], + 'availableThemes', 'availableApps', 'nextUpdateCheck', 'displayUpdateNotification'], tag: ['id', 'uuid', 'name', 'slug', 'description', 'parent_id', 'meta_title', 'meta_description', 'created_at', 'created_by', 'updated_at', 'updated_by'], user: ['id', 'uuid', 'name', 'slug', 'email', 'image', 'cover', 'bio', 'website', From b4ea8bed61ef3d70a8806e877475580edb1dc42d Mon Sep 17 00:00:00 2001 From: Jacob Gable Date: Fri, 28 Feb 2014 13:52:32 -0600 Subject: [PATCH 10/27] Refactor require-tree to not share messages - Pass in messages to each method so they are not shared - Export each method for calling individually - Update reference to default export of require-tree - Add default values for messages if not passed in --- core/server/config/index.js | 2 +- core/server/require-tree.js | 33 ++++++++++++++++++++++++++------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/core/server/config/index.js b/core/server/config/index.js index 1044891060..c63ec43163 100644 --- a/core/server/config/index.js +++ b/core/server/config/index.js @@ -7,7 +7,7 @@ var path = require('path'), when = require('when'), url = require('url'), _ = require('lodash'), - requireTree = require('../require-tree'), + requireTree = require('../require-tree').readAll, theme = require('./theme'), configUrl = require('./url'), ghostConfig = {}, diff --git a/core/server/require-tree.js b/core/server/require-tree.js index af957b06b5..339783f7a3 100644 --- a/core/server/require-tree.js +++ b/core/server/require-tree.js @@ -3,8 +3,13 @@ var _ = require('lodash'), keys = require('when/keys'), path = require('path'), when = require('when'), - messages = {errors: [], warns: []}, - parsePackageJson = function (path) { + parsePackageJson = function (path, messages) { + // Default the messages if non were passed + messages = messages || { + errors: [], + warns: [] + }; + var packageDeferred = when.defer(), packagePromise = packageDeferred.promise, jsonContainer; @@ -30,8 +35,12 @@ var _ = require('lodash'), }); return when(packagePromise); }, - readDir = function (dir, options, depth) { + readDir = function (dir, options, depth, messages) { depth = depth || 0; + messages = messages || { + errors: [], + warns: [] + }; options = _.extend({ index: true @@ -60,9 +69,9 @@ var _ = require('lodash'), fs.lstat(fpath, function (error, result) { /*jslint unparam:true*/ if (result.isDirectory()) { - fileDeferred.resolve(readDir(fpath, options, depth + 1)); + fileDeferred.resolve(readDir(fpath, options, depth + 1, messages)); } else if (depth === 1 && file === "package.json") { - fileDeferred.resolve(parsePackageJson(fpath)); + fileDeferred.resolve(parsePackageJson(fpath, messages)); } else { fileDeferred.resolve(fpath); } @@ -79,7 +88,13 @@ var _ = require('lodash'), }); }, readAll = function (dir, options, depth) { - return when(readDir(dir, options, depth)).then(function (paths) { + // Start with clean messages, pass down along traversal + var messages = { + errors: [], + warns: [] + }; + + return when(readDir(dir, options, depth, messages)).then(function (paths) { // for all contents of the dir, I'm interested in the ones that are directories and within /theme/ if (typeof paths === "object" && dir.indexOf('theme') !== -1) { _.each(paths, function (path, index) { @@ -93,4 +108,8 @@ var _ = require('lodash'), }); }; -module.exports = readAll; +module.exports = { + readAll: readAll, + readDir: readDir, + parsePackageJson: parsePackageJson +}; \ No newline at end of file From 7155d95f9d1dcc78bb1a1a16decff782323b7416 Mon Sep 17 00:00:00 2001 From: Sebastian Gierlinger Date: Wed, 26 Feb 2014 18:51:01 +0100 Subject: [PATCH 11/27] Add JSON API tests & cleanup first 10 % of #2124 - added initial version of JSON API tests - renamed error.errorCode to error.code - renamed tags.all to tags.browse for consistency --- Gruntfile.js | 5 +- core/server/api/db.js | 6 +- core/server/api/index.js | 2 +- core/server/api/posts.js | 25 ++++---- core/server/api/settings.js | 6 +- core/server/api/tags.js | 8 ++- core/server/api/users.js | 10 +--- core/server/apps/proxy.js | 2 +- core/server/controllers/frontend.js | 2 +- core/server/filters.js | 2 +- core/server/middleware/ghost-busboy.js | 2 +- core/server/routes/api.js | 2 +- .../integration/api/api_notifications_spec.js | 49 +++++++++++++++ core/test/integration/api/api_posts_spec.js | 59 +++++++++++++++++++ .../test/integration/api/api_settings_spec.js | 42 +++++++++++++ core/test/integration/api/api_tags_spec.js | 41 +++++++++++++ core/test/integration/api/api_users_spec.js | 41 +++++++++++++ .../integration/model/model_posts_spec.js | 12 ++-- core/test/utils/api.js | 3 +- 19 files changed, 276 insertions(+), 43 deletions(-) create mode 100644 core/test/integration/api/api_notifications_spec.js create mode 100644 core/test/integration/api/api_posts_spec.js create mode 100644 core/test/integration/api/api_settings_spec.js create mode 100644 core/test/integration/api/api_tags_spec.js create mode 100644 core/test/integration/api/api_users_spec.js diff --git a/Gruntfile.js b/Gruntfile.js index d1c60be155..ed5777da3d 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -227,7 +227,10 @@ var path = require('path'), }, integration: { - src: ['core/test/integration/**/model*_spec.js'] + src: [ + 'core/test/integration/**/model*_spec.js', + 'core/test/integration/**/api*_spec.js' + ] }, api: { diff --git a/core/server/api/db.js b/core/server/api/db.js index 3d1bb83d09..5db32833ca 100644 --- a/core/server/api/db.js +++ b/core/server/api/db.js @@ -34,7 +34,7 @@ db = { * - If there is no path * - If the name doesn't have json in it */ - return when.reject({errorCode: 500, message: 'Please select a .json file to import.'}); + return when.reject({code: 500, message: 'Please select a .json file to import.'}); } return api.settings.read({ key: 'databaseVersion' }).then(function (setting) { @@ -99,7 +99,7 @@ db = { }).then(function () { return when.resolve({message: 'Posts, tags and other data successfully imported'}); }).otherwise(function importFailure(error) { - return when.reject({errorCode: 500, message: error.message || error}); + return when.reject({code: 500, message: error.message || error}); }); }, 'deleteAllContent': function () { @@ -107,7 +107,7 @@ db = { .then(function () { return when.resolve({message: 'Successfully deleted all content from your blog.'}); }, function (error) { - return when.reject({errorCode: 500, message: error.message || error}); + return when.reject({code: 500, message: error.message || error}); }); } }; diff --git a/core/server/api/index.js b/core/server/api/index.js index 67fc05b290..ccea747840 100644 --- a/core/server/api/index.js +++ b/core/server/api/index.js @@ -61,7 +61,7 @@ requestHandler = function (apiMethod) { } }); }, function (error) { - var errorCode = error.errorCode || 500, + var errorCode = error.code || 500, errorMsg = {error: _.isString(error) ? error : (_.isObject(error) ? error.message : 'Unknown API Error')}; res.json(errorCode, errorMsg); }); diff --git a/core/server/api/posts.js b/core/server/api/posts.js index b4be27853d..96ad75932c 100644 --- a/core/server/api/posts.js +++ b/core/server/api/posts.js @@ -1,8 +1,7 @@ var when = require('when'), _ = require('lodash'), dataProvider = require('../models'), - permissions = require('../permissions'), - canThis = permissions.canThis, + canThis = require('../permissions').canThis, filteredUserAttributes = require('./users').filteredAttributes, posts; @@ -15,7 +14,7 @@ posts = { options = options || {}; // **returns:** a promise for a page of posts in a json object - //return dataProvider.Post.findPage(options); + return dataProvider.Post.findPage(options).then(function (result) { var i = 0, omitted = result; @@ -43,7 +42,7 @@ posts = { omitted.user = _.omit(omitted.user, filteredUserAttributes); return omitted; } - return when.reject({errorCode: 404, message: 'Post not found'}); + return when.reject({code: 404, message: 'Post not found'}); }); }, @@ -53,7 +52,7 @@ posts = { if (slug) { return slug; } - return when.reject({errorCode: 500, message: 'Could not generate slug'}); + return when.reject({code: 500, message: 'Could not generate slug'}); }); }, @@ -63,7 +62,7 @@ posts = { edit: function edit(postData) { // **returns:** a promise for the resulting post in a json object if (!this.user) { - return when.reject({errorCode: 403, message: 'You do not have permission to edit this post.'}); + return when.reject({code: 403, message: 'You do not have permission to edit this post.'}); } var self = this; return canThis(self.user).edit.post(postData.id).then(function () { @@ -74,17 +73,17 @@ posts = { omitted.user = _.omit(omitted.user, filteredUserAttributes); return omitted; } - return when.reject({errorCode: 404, message: 'Post not found'}); + return when.reject({code: 404, message: 'Post not found'}); }).otherwise(function (error) { return dataProvider.Post.findOne({id: postData.id, status: 'all'}).then(function (result) { if (!result) { - return when.reject({errorCode: 404, message: 'Post not found'}); + return when.reject({code: 404, message: 'Post not found'}); } return when.reject({message: error.message}); }); }); }, function () { - return when.reject({errorCode: 403, message: 'You do not have permission to edit this post.'}); + return when.reject({code: 403, message: 'You do not have permission to edit this post.'}); }); }, @@ -94,13 +93,13 @@ posts = { add: function add(postData) { // **returns:** a promise for the resulting post in a json object if (!this.user) { - return when.reject({errorCode: 403, message: 'You do not have permission to add posts.'}); + return when.reject({code: 403, message: 'You do not have permission to add posts.'}); } return canThis(this.user).create.post().then(function () { return dataProvider.Post.add(postData); }, function () { - return when.reject({errorCode: 403, message: 'You do not have permission to add posts.'}); + return when.reject({code: 403, message: 'You do not have permission to add posts.'}); }); }, @@ -110,7 +109,7 @@ posts = { destroy: function destroy(args) { // **returns:** a promise for a json response with the id of the deleted post if (!this.user) { - return when.reject({errorCode: 403, message: 'You do not have permission to remove posts.'}); + return when.reject({code: 403, message: 'You do not have permission to remove posts.'}); } return canThis(this.user).remove.post(args.id).then(function () { @@ -121,7 +120,7 @@ posts = { }); }); }, function () { - return when.reject({errorCode: 403, message: 'You do not have permission to remove posts.'}); + return when.reject({code: 403, message: 'You do not have permission to remove posts.'}); }); } }; diff --git a/core/server/api/settings.js b/core/server/api/settings.js index f74d67d54f..ab8d4bb1ca 100644 --- a/core/server/api/settings.js +++ b/core/server/api/settings.js @@ -167,7 +167,7 @@ settings = { if (settingsCache) { return when(settingsCache[options.key]).then(function (setting) { if (!setting) { - return when.reject({errorCode: 404, message: 'Unable to find setting: ' + options.key}); + return when.reject({code: 404, message: 'Unable to find setting: ' + options.key}); } var res = {}; res.key = options.key; @@ -202,7 +202,7 @@ settings = { }).otherwise(function (error) { return dataProvider.Settings.read(key.key).then(function (result) { if (!result) { - return when.reject({errorCode: 404, message: 'Unable to find setting: ' + key}); + return when.reject({code: 404, message: 'Unable to find setting: ' + key}); } return when.reject({message: error.message}); }); @@ -210,7 +210,7 @@ settings = { } return dataProvider.Settings.read(key).then(function (setting) { if (!setting) { - return when.reject({errorCode: 404, message: 'Unable to find setting: ' + key}); + return when.reject({code: 404, message: 'Unable to find setting: ' + key}); } if (!_.isString(value)) { value = JSON.stringify(value); diff --git a/core/server/api/tags.js b/core/server/api/tags.js index ae0f0561b3..f73936585e 100644 --- a/core/server/api/tags.js +++ b/core/server/api/tags.js @@ -3,12 +3,14 @@ var dataProvider = require('../models'), tags = { - // #### All + // #### Browse // **takes:** Nothing yet - all: function browse() { + browse: function browse() { // **returns:** a promise for all tags which have previously been used in a json object - return dataProvider.Tag.findAll(); + return dataProvider.Tag.findAll().then(function (result) { + return result.toJSON(); + }); } }; diff --git a/core/server/api/users.js b/core/server/api/users.js index 1ed3c095da..d77a3071dd 100644 --- a/core/server/api/users.js +++ b/core/server/api/users.js @@ -8,8 +8,8 @@ var when = require('when'), // ## Users users = { - // #### Browse + // #### Browse // **takes:** options object browse: function browse(options) { // **returns:** a promise for a collection of users in a json object @@ -31,7 +31,6 @@ users = { }, // #### Read - // **takes:** an identifier (id or slug?) read: function read(args) { // **returns:** a promise for a single user in a json object @@ -45,12 +44,11 @@ users = { return omitted; } - return when.reject({errorCode: 404, message: 'User not found'}); + return when.reject({code: 404, message: 'User not found'}); }); }, // #### Edit - // **takes:** a json object representing a user edit: function edit(userData) { // **returns:** a promise for the resulting user in a json object @@ -60,12 +58,11 @@ users = { var omitted = _.omit(result.toJSON(), filteredAttributes); return omitted; } - return when.reject({errorCode: 404, message: 'User not found'}); + return when.reject({code: 404, message: 'User not found'}); }); }, // #### Add - // **takes:** a json object representing a user add: function add(userData) { @@ -83,7 +80,6 @@ users = { }, // #### Change Password - // **takes:** a json object representing a user changePassword: function changePassword(userData) { // **returns:** on success, returns a promise for the resulting user in a json object diff --git a/core/server/apps/proxy.js b/core/server/apps/proxy.js index 6689dbe7fe..fdf6e2e48b 100644 --- a/core/server/apps/proxy.js +++ b/core/server/apps/proxy.js @@ -15,7 +15,7 @@ var proxy = { }, api: { posts: _.pick(api.posts, 'browse', 'read'), - tags: api.tags, + tags: _.pick(api.tags, 'browse'), notifications: _.pick(api.notifications, 'add'), settings: _.pick(api.settings, 'read') } diff --git a/core/server/controllers/frontend.js b/core/server/controllers/frontend.js index 609d6e6f8c..90310596cd 100644 --- a/core/server/controllers/frontend.js +++ b/core/server/controllers/frontend.js @@ -59,7 +59,7 @@ function formatPageResponse(posts, page) { function handleError(next) { return function (err) { var e = new Error(err.message); - e.status = err.errorCode; + e.status = err.code; return next(e); }; } diff --git a/core/server/filters.js b/core/server/filters.js index 49e63ac0d4..393fdaf670 100644 --- a/core/server/filters.js +++ b/core/server/filters.js @@ -25,7 +25,7 @@ var Filters = function () { // Register a new filter callback function Filters.prototype.registerFilter = function (name, priority, fn) { - // Curry the priority optional parameter to a default of 5 + // Carry the priority optional parameter to a default of 5 if (_.isFunction(priority)) { fn = priority; priority = null; diff --git a/core/server/middleware/ghost-busboy.js b/core/server/middleware/ghost-busboy.js index 4531dec15f..a55d3be67c 100644 --- a/core/server/middleware/ghost-busboy.js +++ b/core/server/middleware/ghost-busboy.js @@ -55,7 +55,7 @@ function ghostBusBoy(req, res, next) { busboy.on('limit', function () { hasError = true; - res.send(413, { errorCode: 413, message: 'File size limit breached.' }); + res.send(413, {code: 413, message: 'File size limit breached.'}); }); busboy.on('error', function (error) { diff --git a/core/server/routes/api.js b/core/server/routes/api.js index b5b3cb8bb8..e14f97f824 100644 --- a/core/server/routes/api.js +++ b/core/server/routes/api.js @@ -19,7 +19,7 @@ module.exports = function (server) { server.get('/ghost/api/v0.1/users/:id/', api.requestHandler(api.users.read)); server.put('/ghost/api/v0.1/users/:id/', api.requestHandler(api.users.edit)); // #### Tags - server.get('/ghost/api/v0.1/tags/', api.requestHandler(api.tags.all)); + server.get('/ghost/api/v0.1/tags/', api.requestHandler(api.tags.browse)); // #### Notifications server.del('/ghost/api/v0.1/notifications/:id', api.requestHandler(api.notifications.destroy)); server.post('/ghost/api/v0.1/notifications/', api.requestHandler(api.notifications.add)); diff --git a/core/test/integration/api/api_notifications_spec.js b/core/test/integration/api/api_notifications_spec.js new file mode 100644 index 0000000000..82155b48ae --- /dev/null +++ b/core/test/integration/api/api_notifications_spec.js @@ -0,0 +1,49 @@ +/*globals describe, before, beforeEach, afterEach, it */ +var testUtils = require('../../utils'), + should = require('should'), + + // Stuff we are testing + DataGenerator = require('../../utils/fixtures/data-generator'), + NotificationsAPI = require('../../../server/api/notifications'); + +describe('Notifications API', function () { + + before(function (done) { + testUtils.clearData().then(function () { + done(); + }, done); + }); + + beforeEach(function (done) { + testUtils.initData() + .then(function () { + return testUtils.insertDefaultFixtures(); + }) + .then(function () { + done(); + }, done); + }); + + afterEach(function (done) { + testUtils.clearData().then(function () { + done(); + }, done); + }); + + it('can browse', function (done) { + var msg = { + type: 'error', // this can be 'error', 'success', 'warn' and 'info' + message: 'This is an error', // A string. Should fit in one line. + status: 'persistent', // or 'passive' + id: 'auniqueid' // A unique ID + }; + NotificationsAPI.add(msg).then(function (notification){ + NotificationsAPI.browse().then(function (results) { + should.exist(results); + results.length.should.be.above(0); + testUtils.API.checkResponse(results[0], 'notification'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/core/test/integration/api/api_posts_spec.js b/core/test/integration/api/api_posts_spec.js new file mode 100644 index 0000000000..107316dbdd --- /dev/null +++ b/core/test/integration/api/api_posts_spec.js @@ -0,0 +1,59 @@ +/*globals describe, before, beforeEach, afterEach, it */ +var testUtils = require('../../utils'), + should = require('should'), + + // Stuff we are testing + DataGenerator = require('../../utils/fixtures/data-generator'), + PostAPI = require('../../../server/api/posts'); + +describe('Post API', function () { + + before(function (done) { + testUtils.clearData().then(function () { + done(); + }, done); + }); + + beforeEach(function (done) { + testUtils.initData() + .then(function () { + return testUtils.insertDefaultFixtures(); + }) + .then(function () { + done(); + }, done); + }); + + afterEach(function (done) { + testUtils.clearData().then(function () { + done(); + }, done); + }); + + it('can browse', function (done) { + PostAPI.browse().then(function (results) { + should.exist(results); + testUtils.API.checkResponse(results, 'posts'); + should.exist(results.posts); + results.posts.length.should.be.above(0); + testUtils.API.checkResponse(results.posts[0], 'post'); + done(); + }).then(null, done); + }); + + it('can read', function (done) { + var firstPost; + + PostAPI.browse().then(function (results) { + should.exist(results); + should.exist(results.posts); + results.posts.length.should.be.above(0); + firstPost = results.posts[0]; + return PostAPI.read({slug: firstPost.slug}); + }).then(function (found) { + should.exist(found); + testUtils.API.checkResponse(found, 'post'); + done(); + }).then(null, done); + }); +}); \ No newline at end of file diff --git a/core/test/integration/api/api_settings_spec.js b/core/test/integration/api/api_settings_spec.js new file mode 100644 index 0000000000..7e302f4c0f --- /dev/null +++ b/core/test/integration/api/api_settings_spec.js @@ -0,0 +1,42 @@ +/*globals describe, before, beforeEach, afterEach, it */ +var testUtils = require('../../utils'), + should = require('should'), + + // Stuff we are testing + DataGenerator = require('../../utils/fixtures/data-generator'), + SettingsAPI = require('../../../server/api/settings'); + +describe('Settings API', function () { + + before(function (done) { + testUtils.clearData().then(function () { + done(); + }, done); + }); + + beforeEach(function (done) { + testUtils.initData() + .then(function () { + return testUtils.insertDefaultFixtures(); + }) + .then(function () { + done(); + }, done); + }); + + afterEach(function (done) { + testUtils.clearData().then(function () { + done(); + }, done); + }); + + it('can browse', function (done) { + SettingsAPI.updateSettingsCache().then(function () { + SettingsAPI.browse('blog').then(function (results) { + should.exist(results); + testUtils.API.checkResponse(results, 'settings'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/core/test/integration/api/api_tags_spec.js b/core/test/integration/api/api_tags_spec.js new file mode 100644 index 0000000000..dd34a975ac --- /dev/null +++ b/core/test/integration/api/api_tags_spec.js @@ -0,0 +1,41 @@ +/*globals describe, before, beforeEach, afterEach, it */ +var testUtils = require('../../utils'), + should = require('should'), + + // Stuff we are testing + DataGenerator = require('../../utils/fixtures/data-generator'), + TagsAPI = require('../../../server/api/tags'); + +describe('Tags API', function () { + + before(function (done) { + testUtils.clearData().then(function () { + done(); + }, done); + }); + + beforeEach(function (done) { + testUtils.initData() + .then(function () { + return testUtils.insertDefaultFixtures(); + }) + .then(function () { + done(); + }, done); + }); + + afterEach(function (done) { + testUtils.clearData().then(function () { + done(); + }, done); + }); + + it('can browse', function (done) { + TagsAPI.browse().then(function (results) { + should.exist(results); + results.length.should.be.above(0); + testUtils.API.checkResponse(results[0], 'tag'); + done(); + }).then(null, done); + }); +}); \ No newline at end of file diff --git a/core/test/integration/api/api_users_spec.js b/core/test/integration/api/api_users_spec.js new file mode 100644 index 0000000000..9dfe467248 --- /dev/null +++ b/core/test/integration/api/api_users_spec.js @@ -0,0 +1,41 @@ +/*globals describe, before, beforeEach, afterEach, it */ +var testUtils = require('../../utils'), + should = require('should'), + + // Stuff we are testing + DataGenerator = require('../../utils/fixtures/data-generator'), + UsersAPI = require('../../../server/api/users'); + +describe('Users API', function () { + + before(function (done) { + testUtils.clearData().then(function () { + done(); + }, done); + }); + + beforeEach(function (done) { + testUtils.initData() + .then(function () { + return testUtils.insertDefaultFixtures(); + }) + .then(function () { + done(); + }, done); + }); + + afterEach(function (done) { + testUtils.clearData().then(function () { + done(); + }, done); + }); + + it('can browse', function (done) { + UsersAPI.browse().then(function (results) { + should.exist(results); + results.length.should.be.above(0); + testUtils.API.checkResponse(results[0], 'user'); + done(); + }).then(null, done); + }); +}); \ No newline at end of file diff --git a/core/test/integration/model/model_posts_spec.js b/core/test/integration/model/model_posts_spec.js index 98acee32fb..e702019150 100644 --- a/core/test/integration/model/model_posts_spec.js +++ b/core/test/integration/model/model_posts_spec.js @@ -1,13 +1,13 @@ /*globals describe, before, beforeEach, afterEach, it */ -var testUtils = require('../../utils'), - should = require('should'), - _ = require('lodash'), - when = require('when'), - sequence = require('when/sequence'), +var testUtils = require('../../utils'), + should = require('should'), + _ = require('lodash'), + when = require('when'), + sequence = require('when/sequence'), // Stuff we are testing DataGenerator = require('../../utils/fixtures/data-generator'), - Models = require('../../../server/models'); + Models = require('../../../server/models'); describe('Post Model', function () { diff --git a/core/test/utils/api.js b/core/test/utils/api.js index 92a21c3e89..2284ba4a73 100644 --- a/core/test/utils/api.js +++ b/core/test/utils/api.js @@ -17,7 +17,8 @@ var _ = require('lodash'), 'meta_title', 'meta_description', 'created_at', 'created_by', 'updated_at', 'updated_by'], user: ['id', 'uuid', 'name', 'slug', 'email', 'image', 'cover', 'bio', 'website', 'location', 'accessibility', 'status', 'language', 'meta_title', 'meta_description', - 'created_at', 'updated_at'] + 'created_at', 'updated_at'], + notification: ['type', 'message', 'status', 'id'] }; From b461cc6138be4e771f8b8954919bbfcbce1303e0 Mon Sep 17 00:00:00 2001 From: Gabor Javorszky Date: Sat, 1 Mar 2014 08:57:14 +0000 Subject: [PATCH 12/27] Update link to clean up history in contributing.md Fixes #2296 Also replaces line endings, not sure why (tried with all the settings, git would just not have it) --- CONTRIBUTING.md | 104 ++++++++++++++++++++++++------------------------ 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 090838ac73..d3162267aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,11 +1,11 @@ # Contributing to Ghost -So you're interested in giving us a hand? That's awesome! We've put together some brief guidelines that should help +So you're interested in giving us a hand? That's awesome! We've put together some brief guidelines that should help you get started quickly and easily. There are lots and lots of ways to get involved, this document covers: -* [raising issues](#raising-issues) +* [raising issues](#raising-issues) * [bug reports](#bugs) * [feature requests](#features) * [change requests](#changes) @@ -19,14 +19,14 @@ There are lots and lots of ways to get involved, this document covers: ## Reporting An Issue -If you're about to raise an issue because think you've found a problem with Ghost, or you'd like to make a request +If you're about to raise an issue because think you've found a problem with Ghost, or you'd like to make a request for a new feature in the codebase, or any other reason… please read this first. The GitHub issue tracker is the preferred channel for [bug reports](#bugs), [feature requests](#features), [change requests](#changes) and [submitting pull requests](#pull-requests), but please respect the following restrictions: -* Please **search for existing issues**. Help us keep duplicate issues to a minimum by checking to see if someone +* Please **search for existing issues**. Help us keep duplicate issues to a minimum by checking to see if someone has already reported your problem or requested your idea. * Please **do not** use the issue tracker for personal support requests (use @@ -51,13 +51,13 @@ Guidelines for bug reports: 3. **Isolate the problem** — ideally create a [reduced test case](http://css-tricks.com/6263-reduced-test-cases/) and a live example. -4. **Include a screencast if relevant** - Is your issue about a design or front end feature or bug? The most -helpful thing in the world is if we can *see* what you're talking about. +4. **Include a screencast if relevant** - Is your issue about a design or front end feature or bug? The most +helpful thing in the world is if we can *see* what you're talking about. Use [LICEcap](http://www.cockos.com/licecap/) to quickly and easily record a short screencast (24fps) and save it as an animated gif! Embed it directly into your GitHub issue. Kapow. 5. Use the Bug Report template below or [click this link](https://github.com/TryGhost/Ghost/issues/new?title=Bug%3A&body=%23%23%23%20Issue%20Summary%0A%0A%23%23%23%20Steps%20to%20Reproduce%0A%0A1.%20This%20is%20the%20first%20step%0A%0AThis%20is%20a%20bug%20because...%0A%0A%23%23%23%20Technical%20details%0A%0A*%20Ghost%20Version%3A%20master%20-%20latest%20commit%3A%20%20INSERT%20COMMIT%20REF%0A*%20Client%20OS%3A%20%0A*%20Server%20OS%3A%20%0A*%20Node%20Version%3A%20%0A*%20Browser%3A) to start creating a bug report with the template automatically. -A good bug report shouldn't leave others needing to chase you up for more information. Be sure to include the +A good bug report shouldn't leave others needing to chase you up for more information. Be sure to include the details of your environment. Here is a [real example](https://github.com/TryGhost/Ghost/issues/413) @@ -80,7 +80,7 @@ suitable, include the steps required to reproduce the bug. Any other information you want to share that is relevant to the issue being reported. Especially, why do you consider this to be a bug? What do you expect to happen instead? -### Technical details: +### Technical details: * Ghost Version: master (latest commit: 590ba48988b51b9c5e8d99afbb84c997436d7f21) * Client OS: Mac OS X 10.8.4 @@ -95,38 +95,38 @@ reported. Especially, why do you consider this to be a bug? What do you expect t Feature requests are welcome. Before you submit one be sure to have: 1. Read the [Roadmap](https://github.com/TryGhost/Ghost/wiki/Roadmap) and -[Planned Features](https://github.com/TryGhost/Ghost/wiki/Planned-Features) listing, **use the GitHub search** and +[Planned Features](https://github.com/TryGhost/Ghost/wiki/Planned-Features) listing, **use the GitHub search** and check the feature hasn't already been requested. -2. Take a moment to think about whether your idea fits with the scope and aims of the project, or if it might +2. Take a moment to think about whether your idea fits with the scope and aims of the project, or if it might better fit being an app/plugin. -3. Remember, it's up to *you* to make a strong case to convince the project's leaders of the merits of this -feature. Please provide as much detail and context as possible, this means explaining the use case and why it is -likely to be common. +3. Remember, it's up to *you* to make a strong case to convince the project's leaders of the merits of this +feature. Please provide as much detail and context as possible, this means explaining the use case and why it is +likely to be common. 4. Clearly indicate whether this is a feature request for Ghost admin, or for themes or apps. ### Change Requests -Change requests cover both architectural and functional changes to how Ghost works. If you have an idea for a +Change requests cover both architectural and functional changes to how Ghost works. If you have an idea for a new or different dependency, a refactor, or an improvement to a feature, etc - please be sure to: 1. **Use the GitHub search** and check someone else didn't get there first -2. Take a moment to think about the best way to make a case for, and explain what you're thinking. Are you sure +2. Take a moment to think about the best way to make a case for, and explain what you're thinking. Are you sure this shouldn't really be a [bug report](#bug-reports) or a [feature request](#feature-requests)? Is it really one idea or is it many? What's the context? What problem are you solving? Why is what you are suggesting better than -what's already there? Does it fit with the Roadmap? +what's already there? Does it fit with the Roadmap? ### Submitting Pull Requests -Pull requests are awesome. If you're looking to raise a PR for something which doesn't have an open issue, please think carefully about [raising an issue](#raising-issues) which your PR can close, especially if you're fixing a bug. This makes it more likely that there will be enough information available for your PR to be properly tested and merged. To make sure your PR is accepted as quickly as possible, you should be sure to have read +Pull requests are awesome. If you're looking to raise a PR for something which doesn't have an open issue, please think carefully about [raising an issue](#raising-issues) which your PR can close, especially if you're fixing a bug. This makes it more likely that there will be enough information available for your PR to be properly tested and merged. To make sure your PR is accepted as quickly as possible, you should be sure to have read all the guidelines on: * [code standards](https://github.com/TryGhost/Ghost/wiki/Code-standards) * [commit messages](https://github.com/TryGhost/Ghost/wiki/Git-workflow#commit-messages) -* [cleaning-up history](https://github.com/TryGhost/Ghost/wiki/Git-workflow#clean-up-history) +* [cleaning-up history](https://github.com/TryGhost/Ghost/wiki/Git-workflow#wiki-clean-up-history) * [not breaking the build](https://github.com/TryGhost/Ghost/wiki/Git-workflow#check-it-passes-the-tests) ##### Need Help? @@ -138,29 +138,29 @@ If you're not completely clear on how to submit / update / *do* Pull Requests, p ### Testing and Quality Assurance -Never underestimate just how useful quality assurance is. If you're looking to get involved with the code base and +Never underestimate just how useful quality assurance is. If you're looking to get involved with the code base and don't know where to start, checking out and testing a pull request is one of the most useful things you could do. -If you want to get involved with testing Ghost, there is a set of +If you want to get involved with testing Ghost, there is a set of [QA Documentation](https://github.com/TryGhost/Ghost/wiki/QA-Documentation) on the wiki. -Essentially though, [check out the latest master](#core), take it for a spin, and if you find anything odd, please +Essentially though, [check out the latest master](#core), take it for a spin, and if you find anything odd, please follow the [bug report guidelines](#bug-reports) and let us know! #### Checking out a Pull Request -These are some [excellent instructions](https://gist.github.com/piscisaureus/3342247) on configuring your GitHub -repository to allow you to checkout pull requests in the same way as branches: +These are some [excellent instructions](https://gist.github.com/piscisaureus/3342247) on configuring your GitHub +repository to allow you to checkout pull requests in the same way as branches: . ### Documentation -Ghost's main documentation can be found at [docs.ghost.org](http://docs.ghost.org). +Ghost's main documentation can be found at [docs.ghost.org](http://docs.ghost.org). -The documentation is generated using jekyll, all of the docs are on the gh-pages branch on the GitHub repository. -You can clone the repo, checkout the gh-pages branch, and submit pull requests following +The documentation is generated using jekyll, all of the docs are on the gh-pages branch on the GitHub repository. +You can clone the repo, checkout the gh-pages branch, and submit pull requests following the [pull-request](#pull-requests) guidelines. @@ -174,7 +174,7 @@ Full documentation on contributing translations can be found at ## Working on Ghost Core -**Note:** It is recommended that you use the [Ghost-Vagrant](https://github.com/TryGhost/Ghost-Vagrant) setup for +**Note:** It is recommended that you use the [Ghost-Vagrant](https://github.com/TryGhost/Ghost-Vagrant) setup for developing Ghost. **Pre-requisites:** @@ -215,25 +215,25 @@ Addresses for development: ### Updating with the latest changes -Pulling down the latest changes from master will often require more than just a pull, you may also need to do one +Pulling down the latest changes from master will often require more than just a pull, you may also need to do one or more of the following: * `npm install` - fetch any new dependencies * `git submodule update` - fetch the latest changes to Casper (the default theme) - * `grunt` - will recompile handlebars templates and sass for the admin (as long as you have previously + * `grunt` - will recompile handlebars templates and sass for the admin (as long as you have previously run `grunt init` to install bourbon) * delete content/data/*.db - delete the database and allow Ghost to recreate the fixtures ### Key Branches & Tags -- **[master](https://github.com/TryGhost/Ghost)** is the bleeding edge development branch. All work on the next +- **[master](https://github.com/TryGhost/Ghost)** is the bleeding edge development branch. All work on the next release is here. - **[gh-pages](http://tryghost.github.io/Ghost)** is The Ghost Guide documentation for Getting Started with Ghost. ### Compiling CSS & JavaScript -A SASS compiler is required to work with the CSS in this project. You can either do this by running `grunt` from -the command line - or by using a 3rd party app. We recommend [CodeKit](http://incident57.com/codekit/) (Paid/Mac) +A SASS compiler is required to work with the CSS in this project. You can either do this by running `grunt` from +the command line - or by using a 3rd party app. We recommend [CodeKit](http://incident57.com/codekit/) (Paid/Mac) & [Scout](http://mhs.github.io/scout-app/) (Free/Mac/PC). You will need to have Ruby installed, as well as having run `gem install sass && gem install bourbon`. @@ -241,19 +241,19 @@ You will need to have Ruby installed, as well as having run `gem install sass && Ghost uses Grunt heavily to automate useful tasks such as building assets, testing, live reloading/watching etc etc -[Grunt Toolkit docs](https://github.com/TryGhost/Ghost/wiki/Grunt-Toolkit) are a worthwhile read for any would-be +[Grunt Toolkit docs](https://github.com/TryGhost/Ghost/wiki/Grunt-Toolkit) are a worthwhile read for any would-be contributor. ## Troubleshooting / FAQ ### I get "ERROR: Failed to lookup view "index" -Sounds like you don't have our default theme - Casper, your content/themes/casper folder is probably empty. -When cloning from GitHub be sure to use SSH and to run `git submodule update --init`. +Sounds like you don't have our default theme - Casper, your content/themes/casper folder is probably empty. +When cloning from GitHub be sure to use SSH and to run `git submodule update --init`. ### I get "Syntax error: File to import not found or unreadable: bourbon/_bourbon." -Sounds like you don't have the Ruby gem "bourbon" installed. Make sure you have Ruby, and then +Sounds like you don't have the Ruby gem "bourbon" installed. Make sure you have Ruby, and then run `gem install bourbon`, and `grunt init`. ### Ghost doesn't do anything - I get a blank screen @@ -262,31 +262,31 @@ Sounds like you probably didn't run the right grunt command for building assets ### SQLite3 doesn't install properly during npm install -Ghost depends upon SQLite3, which requires a native binary. These are provided for most major platforms, but if you -are using a more obscure *nix flavor you may need to follow +Ghost depends upon SQLite3, which requires a native binary. These are provided for most major platforms, but if you +are using a more obscure *nix flavor you may need to follow the [node-sqlite3 binary instructions](https://github.com/developmentseed/node-sqlite3/wiki/Binaries). ## Contributor License Agreement -By contributing your code to Ghost you grant the Ghost Foundation a non-exclusive, irrevocable, worldwide, -royalty-free, sublicenseable, transferable license under all of Your relevant intellectual property rights -(including copyright, patent, and any other rights), to use, copy, prepare derivative works of, distribute and -publicly perform and display the Contributions on any licensing terms, including without limitation: -(a) open source licenses like the MIT license; and (b) binary, proprietary, or commercial licenses. Except for the +By contributing your code to Ghost you grant the Ghost Foundation a non-exclusive, irrevocable, worldwide, +royalty-free, sublicenseable, transferable license under all of Your relevant intellectual property rights +(including copyright, patent, and any other rights), to use, copy, prepare derivative works of, distribute and +publicly perform and display the Contributions on any licensing terms, including without limitation: +(a) open source licenses like the MIT license; and (b) binary, proprietary, or commercial licenses. Except for the licenses granted herein, You reserve all right, title, and interest in and to the Contribution. -You confirm that you are able to grant us these rights. You represent that You are legally entitled to grant the -above license. If Your employer has rights to intellectual property that You create, You represent that You have -received permission to make the Contributions on behalf of that employer, or that Your employer has waived such +You confirm that you are able to grant us these rights. You represent that You are legally entitled to grant the +above license. If Your employer has rights to intellectual property that You create, You represent that You have +received permission to make the Contributions on behalf of that employer, or that Your employer has waived such rights for the Contributions. -You represent that the Contributions are Your original works of authorship, and to Your knowledge, no other person -claims, or has the right to claim, any right in any invention or patent related to the Contributions. You also -represent that You are not legally obligated, whether by entering into an agreement or otherwise, in any way that +You represent that the Contributions are Your original works of authorship, and to Your knowledge, no other person +claims, or has the right to claim, any right in any invention or patent related to the Contributions. You also +represent that You are not legally obligated, whether by entering into an agreement or otherwise, in any way that conflicts with the terms of this license. -The Ghost Foundation acknowledges that, except as explicitly described in this Agreement, any Contribution which -you provide is on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, -INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS +The Ghost Foundation acknowledges that, except as explicitly described in this Agreement, any Contribution which +you provide is on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, +INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. From 561ea0edbb4860381c683efefd16a39adc1687a9 Mon Sep 17 00:00:00 2001 From: Sean Hellwig Date: Fri, 28 Feb 2014 23:25:49 -0800 Subject: [PATCH 13/27] Add plugin icons to Apps menu item in Ghost settings closes #2290 - added css entry in settings.scss for to display plugin icon for apps menu item - remove unused css entry for .plugins in settings.scss --- core/client/assets/sass/layouts/settings.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/client/assets/sass/layouts/settings.scss b/core/client/assets/sass/layouts/settings.scss index 30f235d51a..ba429b5a4f 100644 --- a/core/client/assets/sass/layouts/settings.scss +++ b/core/client/assets/sass/layouts/settings.scss @@ -162,7 +162,7 @@ .services a { @include icon($i-services) } .users a { @include icon($i-users) } .appearance a { @include icon($i-appearance) } - .plugins a { @include icon($i-plugins) } + .apps a { @include icon($i-plugins) } }//.settings-menu From be8b9cf0922886b747285fb284ea42710673ef75 Mon Sep 17 00:00:00 2001 From: Johan Stenehall Date: Sun, 2 Mar 2014 12:46:03 +0100 Subject: [PATCH 14/27] Fixing typo in allowedSections for allowed pages under settings --- core/server/controllers/admin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/server/controllers/admin.js b/core/server/controllers/admin.js index e0fd494446..0ed5c14a98 100644 --- a/core/server/controllers/admin.js +++ b/core/server/controllers/admin.js @@ -89,7 +89,7 @@ adminControllers = { // Method: GET 'settings': function (req, res, next) { // TODO: Centralise list/enumeration of settings panes, so we don't run into trouble in future. - var allowedSections = ['', 'general', 'user', 'app'], + var allowedSections = ['', 'general', 'user', 'apps'], section = req.url.replace(/(^\/ghost\/settings[\/]*|\/$)/ig, ''); if (allowedSections.indexOf(section) < 0) { From ab2656960a9ed7c93b58857d519b7c1d78961ea7 Mon Sep 17 00:00:00 2001 From: Shashank Mehta Date: Mon, 3 Mar 2014 02:30:09 +0530 Subject: [PATCH 15/27] Prevent settings page from rendering same page twice Closes #2316 - There was a check to prevent rerendering of same content pane but it wasn't working - Fixed the check for this --- core/client/views/settings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/client/views/settings.js b/core/client/views/settings.js index 9283924307..bc40977f99 100644 --- a/core/client/views/settings.js +++ b/core/client/views/settings.js @@ -61,7 +61,7 @@ Ghost.router.navigate('/settings/' + id + '/'); Ghost.trigger('urlchange'); - if (this.pane && id === this.pane.el.id) { + if (this.pane && id === this.pane.id) { return; } _.result(this.pane, 'destroy'); From a92c8085c56a16937514c090fa6db47b5b2c616b Mon Sep 17 00:00:00 2001 From: Shashank Mehta Date: Sat, 1 Mar 2014 03:34:05 +0530 Subject: [PATCH 16/27] Shifts app UI behind config option Closes #2287 - adds helper for checking whether to show apps UI or not - hides app UI from settings page --- core/client/views/settings.js | 5 +++++ core/server/helpers/index.js | 16 ++++++++++++++++ core/server/views/settings.hbs | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/core/client/views/settings.js b/core/client/views/settings.js index 9283924307..c19ad1d631 100644 --- a/core/client/views/settings.js +++ b/core/client/views/settings.js @@ -38,6 +38,11 @@ initialize: function (options) { this.render(); this.menu = this.$('.settings-menu'); + // Hides apps UI unless config.js says otherwise + // This will stay until apps UI is ready to ship + if ($(this.el).attr('data-apps') !== "true") { + this.menu.find('.apps').hide(); + } this.showContent(options.pane); }, diff --git a/core/server/helpers/index.js b/core/server/helpers/index.js index 9c4deb8cdf..02a66202f2 100644 --- a/core/server/helpers/index.js +++ b/core/server/helpers/index.js @@ -327,6 +327,20 @@ coreHelpers.file_storage = function (context, options) { return "true"; }; +// ### Apps helper +// +// *Usage example:* +// `{{apps}}` +// +// Returns the config value for apps. +coreHelpers.apps = function (context, options) { + /*jslint unparam:true*/ + if (config().hasOwnProperty('apps')) { + return config().apps.toString(); + } + return "false"; +}; + coreHelpers.ghost_script_tags = function () { var scriptList = isProduction ? scriptFiles.production : scriptFiles.development; @@ -785,6 +799,8 @@ registerHelpers = function (adminHbs, assetHash) { registerAdminHelper('file_storage', coreHelpers.file_storage); + registerAdminHelper('apps', coreHelpers.apps); + registerAdminHelper('admin_url', coreHelpers.admin_url); registerAsyncAdminHelper('update_notification', coreHelpers.update_notification); diff --git a/core/server/views/settings.hbs b/core/server/views/settings.hbs index 9ddbc5b628..253476f313 100644 --- a/core/server/views/settings.hbs +++ b/core/server/views/settings.hbs @@ -1,6 +1,6 @@ {{!< default}}
-