diff --git a/core/frontend/helpers/proxy.js b/core/frontend/helpers/proxy.js index 9d0f387e1e..f359563939 100644 --- a/core/frontend/helpers/proxy.js +++ b/core/frontend/helpers/proxy.js @@ -1,6 +1,6 @@ // This file defines everything that helpers "require" // With the exception of modules like lodash, Bluebird -// We can later refactor to enforce this something like we did in apps +// We can later refactor to enforce this something like we do in apps var hbs = require('../services/themes/engine'), settingsCache = require('../../server/services/settings/cache'), config = require('../../server/config'); diff --git a/core/frontend/services/routing/bootstrap.js b/core/frontend/services/routing/bootstrap.js index 1f09c7e6af..7c43707468 100644 --- a/core/frontend/services/routing/bootstrap.js +++ b/core/frontend/services/routing/bootstrap.js @@ -58,7 +58,7 @@ module.exports.init = (options = {start: false}) => { * 3. Taxonomies: Stronger than collections, because it's an inbuilt feature. * 4. Collections * 5. Static Pages: Weaker than collections, because we first try to find a post slug and fallback to lookup a static page. - * 6. Internal Apps: Weakest + * 6. Apps: Weakest */ module.exports.start = (apiVersion) => { const RESOURCE_CONFIG = require(`./config/${apiVersion}`); diff --git a/core/server/api/canary/slugs.js b/core/server/api/canary/slugs.js index 19dc51f2e7..0d9d6a71e1 100644 --- a/core/server/api/canary/slugs.js +++ b/core/server/api/canary/slugs.js @@ -4,7 +4,8 @@ const common = require('../../lib/common'); const allowedTypes = { post: models.Post, tag: models.Tag, - user: models.User + user: models.User, + app: models.App }; module.exports = { diff --git a/core/server/api/v2/slugs.js b/core/server/api/v2/slugs.js index 19dc51f2e7..0d9d6a71e1 100644 --- a/core/server/api/v2/slugs.js +++ b/core/server/api/v2/slugs.js @@ -4,7 +4,8 @@ const common = require('../../lib/common'); const allowedTypes = { post: models.Post, tag: models.Tag, - user: models.User + user: models.User, + app: models.App }; module.exports = { diff --git a/core/server/data/importer/importers/data/settings.js b/core/server/data/importer/importers/data/settings.js index fe1c60f0f8..f3982ec7d8 100644 --- a/core/server/data/importer/importers/data/settings.js +++ b/core/server/data/importer/importers/data/settings.js @@ -1,11 +1,10 @@ -const debug = require('ghost-ignition').debug('importer:settings'); -const Promise = require('bluebird'); -const _ = require('lodash'); -const BaseImporter = require('./base'); -const models = require('../../../../models'); -const defaultSettings = require('../../../schema').defaultSettings; -const labsDefaults = JSON.parse(defaultSettings.blog.labs.defaultValue); -const deprecatedSettings = ['active_apps', 'installed_apps']; +const debug = require('ghost-ignition').debug('importer:settings'), + Promise = require('bluebird'), + _ = require('lodash'), + BaseImporter = require('./base'), + models = require('../../../../models'), + defaultSettings = require('../../../schema').defaultSettings, + labsDefaults = JSON.parse(defaultSettings.blog.labs.defaultValue); const isFalse = (value) => { // Catches false, null, undefined, empty string @@ -66,9 +65,27 @@ class SettingsImporter extends BaseImporter { }); } - // Don't import any old, deprecated settings + const activeApps = _.find(this.dataToImport, {key: 'active_apps'}); + const installedApps = _.find(this.dataToImport, {key: 'installed_apps'}); + + const hasValueEntries = (setting = {}) => { + try { + return JSON.parse(setting.value || '[]').length !== 0; + } catch (e) { + return false; + } + }; + + if (hasValueEntries(activeApps) || hasValueEntries(installedApps)) { + this.problems.push({ + message: 'Old settings for apps were not imported', + help: this.modelName, + context: JSON.stringify({activeApps, installedApps}) + }); + } + this.dataToImport = _.filter(this.dataToImport, (data) => { - return !_.includes(deprecatedSettings, data.key); + return data.key !== 'active_apps' && data.key !== 'installed_apps'; }); const permalinks = _.find(this.dataToImport, {key: 'permalinks'}); diff --git a/core/server/data/schema/schema.js b/core/server/data/schema/schema.js index e5b503597e..f717d7281d 100644 --- a/core/server/data/schema/schema.js +++ b/core/server/data/schema/schema.js @@ -152,7 +152,7 @@ module.exports = { maxlength: 50, nullable: false, defaultTo: 'core', - validations: {isIn: [['core', 'blog', 'theme', 'private', 'members', 'bulk_email']]} + validations: {isIn: [['core', 'blog', 'theme', 'app', 'plugin', 'private', 'members', 'bulk_email']]} }, created_at: {type: 'dateTime', nullable: false}, created_by: {type: 'string', maxlength: 24, nullable: false}, diff --git a/core/server/index.js b/core/server/index.js index 25dccccd68..aab635f68f 100644 --- a/core/server/index.js +++ b/core/server/index.js @@ -30,6 +30,7 @@ function initialiseServices() { routing.bootstrap.start(themeService.getApiVersion()); const permissions = require('./services/permissions'), + apps = require('./services/apps'), xmlrpc = require('./services/xmlrpc'), slack = require('./services/slack'), {mega} = require('./services/mega'), @@ -45,6 +46,7 @@ function initialiseServices() { slack.listen(), mega.listen(), webhooks.listen(), + apps.init(), scheduling.init({ schedulerUrl: config.get('scheduling').schedulerUrl, active: config.get('scheduling').active, @@ -55,7 +57,7 @@ function initialiseServices() { contentPath: config.getContentPath('scheduling') }) ).then(function () { - debug('XMLRPC, Slack, MEGA, Webhooks, Scheduling, Permissions done'); + debug('XMLRPC, Slack, MEGA, Webhooks, Apps, Scheduling, Permissions done'); // Initialise analytics events if (config.get('segment:key')) { diff --git a/core/server/lib/fs/package-json/filter.js b/core/server/lib/fs/package-json/filter.js index c461599514..d9ac182049 100644 --- a/core/server/lib/fs/package-json/filter.js +++ b/core/server/lib/fs/package-json/filter.js @@ -4,7 +4,7 @@ var _ = require('lodash'), /** * ### Filter Packages - * Normalizes packages read by read-packages so that the themes module can use them. + * Normalizes packages read by read-packages so that the apps and themes modules can use them. * Iterates over each package and return an array of objects which are simplified representations of the package * with 3 properties: * - `name` - the package name @@ -17,10 +17,10 @@ var _ = require('lodash'), * * @param {object} packages as returned by read-packages * @param {array/string} active as read from the settings object - * @returns {Array} of objects with useful info about themes + * @returns {Array} of objects with useful info about apps / themes */ filterPackages = function filterPackages(packages, active) { - // turn active into an array if it isn't one, so this function can deal with lists and one-offs + // turn active into an array (so themes and apps can be checked the same) if (!Array.isArray(active)) { active = [active]; } diff --git a/core/server/lib/fs/package-json/index.js b/core/server/lib/fs/package-json/index.js index cbc3cb022f..0f5a4c1bc5 100644 --- a/core/server/lib/fs/package-json/index.js +++ b/core/server/lib/fs/package-json/index.js @@ -3,7 +3,9 @@ * * Ghost has / is in the process of gaining support for several different types of sub-packages: * - Themes: have always been packages, but we're going to lean more heavily on npm & package.json in future - * - Adapters: replace fundamental pieces like storage, will become npm modules + * - Adapters: an early version of apps, replace fundamental pieces like storage, will become npm modules + * - Apps: plugins that can be installed whilst Ghost is running & modify behaviour + * - More? * * These utils facilitate loading, reading, managing etc, packages from the file system. */ diff --git a/core/server/models/app-field.js b/core/server/models/app-field.js new file mode 100644 index 0000000000..50c1ca38b6 --- /dev/null +++ b/core/server/models/app-field.js @@ -0,0 +1,20 @@ +var ghostBookshelf = require('./base'), + AppField, + AppFields; + +AppField = ghostBookshelf.Model.extend({ + tableName: 'app_fields', + + post: function post() { + return this.morphOne('Post', 'relatable'); + } +}); + +AppFields = ghostBookshelf.Collection.extend({ + model: AppField +}); + +module.exports = { + AppField: ghostBookshelf.model('AppField', AppField), + AppFields: ghostBookshelf.collection('AppFields', AppFields) +}; diff --git a/core/server/models/app-setting.js b/core/server/models/app-setting.js new file mode 100644 index 0000000000..12df2229a9 --- /dev/null +++ b/core/server/models/app-setting.js @@ -0,0 +1,20 @@ +var ghostBookshelf = require('./base'), + AppSetting, + AppSettings; + +AppSetting = ghostBookshelf.Model.extend({ + tableName: 'app_settings', + + app: function app() { + return this.belongsTo('App'); + } +}); + +AppSettings = ghostBookshelf.Collection.extend({ + model: AppSetting +}); + +module.exports = { + AppSetting: ghostBookshelf.model('AppSetting', AppSetting), + AppSettings: ghostBookshelf.collection('AppSettings', AppSettings) +}; diff --git a/core/server/models/app.js b/core/server/models/app.js new file mode 100644 index 0000000000..37cdd3acbf --- /dev/null +++ b/core/server/models/app.js @@ -0,0 +1,60 @@ +var ghostBookshelf = require('./base'), + App, + Apps; + +App = ghostBookshelf.Model.extend({ + tableName: 'apps', + + onSaving: function onSaving(newPage, attr, options) { + var self = this; + + ghostBookshelf.Model.prototype.onSaving.apply(this, arguments); + + if (this.hasChanged('slug') || !this.get('slug')) { + // Pass the new slug through the generator to strip illegal characters, detect duplicates + return ghostBookshelf.Model.generateSlug(App, this.get('slug') || this.get('name'), + {transacting: options.transacting}) + .then(function then(slug) { + self.set({slug: slug}); + }); + } + }, + + permissions: function permissions() { + return this.belongsToMany('Permission', 'permissions_apps'); + }, + + settings: function settings() { + return this.belongsToMany('AppSetting', 'app_settings'); + } +}, { + /** + * Returns an array of keys permitted in a method's `options` hash, depending on the current method. + * @param {String} methodName The name of the method to check valid options for. + * @return {Array} Keys allowed in the `options` hash of the model's method. + */ + permittedOptions: function permittedOptions(methodName) { + var options = ghostBookshelf.Model.permittedOptions.call(this, methodName), + + // whitelists for the `options` hash argument on methods, by method name. + // these are the only options that can be passed to Bookshelf / Knex. + validOptions = { + findOne: ['withRelated'] + }; + + if (validOptions[methodName]) { + options = options.concat(validOptions[methodName]); + } + + return options; + } +}); + +Apps = ghostBookshelf.Collection.extend({ + model: App +}); + +module.exports = { + App: ghostBookshelf.model('App', App), + Apps: ghostBookshelf.collection('Apps', Apps) +}; diff --git a/core/server/models/base/index.js b/core/server/models/base/index.js index 4ed1a5e234..1dcb6c5b31 100644 --- a/core/server/models/base/index.js +++ b/core/server/models/base/index.js @@ -3,9 +3,8 @@ // several basic behaviours such as UUIDs, as well as a set of Data methods for accessing information from the database. // // The models are internal to Ghost, only the API and some internal functions such as migration and import/export -// accesses the models directly. - -// All other parts of Ghost, including the frontend & admin UI are only allowed to access data via the API. +// accesses the models directly. All other parts of Ghost, including the blog frontend, admin UI, and apps are only +// allowed to access data via the API. const _ = require('lodash'), bookshelf = require('bookshelf'), moment = require('moment'), diff --git a/core/server/models/index.js b/core/server/models/index.js index b494ef2c2d..3584425a87 100644 --- a/core/server/models/index.js +++ b/core/server/models/index.js @@ -15,6 +15,9 @@ require('./base/listeners'); exports = module.exports; models = [ + 'app-field', + 'app-setting', + 'app', 'permission', 'post', 'role', diff --git a/core/server/models/invite.js b/core/server/models/invite.js index d75e835bc8..0ad5db26ed 100644 --- a/core/server/models/invite.js +++ b/core/server/models/invite.js @@ -42,11 +42,11 @@ Invite = ghostBookshelf.Model.extend({ return ghostBookshelf.Model.add.call(this, data, options); }, - permissible(inviteModel, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasApiKeyPermission) { + permissible(inviteModel, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasAppPermission, hasApiKeyPermission) { const isAdd = (action === 'add'); if (!isAdd) { - if (hasUserPermission && hasApiKeyPermission) { + if (hasUserPermission && hasAppPermission && hasApiKeyPermission) { return Promise.resolve(); } @@ -86,7 +86,7 @@ Invite = ghostBookshelf.Model.extend({ }); } - if (hasUserPermission && hasApiKeyPermission) { + if (hasUserPermission && hasAppPermission && hasApiKeyPermission) { return Promise.resolve(); } diff --git a/core/server/models/permission.js b/core/server/models/permission.js index 57a67748c0..2d518fbfce 100644 --- a/core/server/models/permission.js +++ b/core/server/models/permission.js @@ -33,6 +33,10 @@ Permission = ghostBookshelf.Model.extend({ users: function users() { return this.belongsToMany('User'); + }, + + apps: function apps() { + return this.belongsToMany('App'); } }); diff --git a/core/server/models/post.js b/core/server/models/post.js index edb692d5f7..a0ceca0a27 100644 --- a/core/server/models/post.js +++ b/core/server/models/post.js @@ -934,7 +934,7 @@ Post = ghostBookshelf.Model.extend({ }, // NOTE: the `authors` extension is the parent of the post model. It also has a permissible function. - permissible: function permissible(postModel, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasApiKeyPermission) { + permissible: function permissible(postModel, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasAppPermission, hasApiKeyPermission) { let isContributor; let isOwner; let isAdmin; @@ -989,7 +989,7 @@ Post = ghostBookshelf.Model.extend({ excludedAttrs.push('tags'); } - if (hasUserPermission && hasApiKeyPermission) { + if (hasUserPermission && hasApiKeyPermission && hasAppPermission) { return Promise.resolve({excludedAttrs}); } diff --git a/core/server/models/relations/authors.js b/core/server/models/relations/authors.js index ff295b3c34..f405119873 100644 --- a/core/server/models/relations/authors.js +++ b/core/server/models/relations/authors.js @@ -331,7 +331,7 @@ module.exports.extendModel = function extendModel(Post, Posts, ghostBookshelf) { return destroyPost(); }, - permissible: function permissible(postModelOrId, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasApiKeyPermission) { + permissible: function permissible(postModelOrId, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasAppPermission, hasApiKeyPermission) { var self = this, postModel = postModelOrId, origArgs, isContributor, isAuthor, isEdit, isAdd, isDestroy; @@ -420,7 +420,7 @@ module.exports.extendModel = function extendModel(Post, Posts, ghostBookshelf) { hasUserPermission = hasUserPermission || isPrimaryAuthor(); } - if (hasUserPermission && hasApiKeyPermission) { + if (hasUserPermission && hasApiKeyPermission && hasAppPermission) { return Post.permissible.call( this, postModelOrId, @@ -428,6 +428,7 @@ module.exports.extendModel = function extendModel(Post, Posts, ghostBookshelf) { unsafeAttrs, loadedPermissions, hasUserPermission, + hasAppPermission, hasApiKeyPermission ).then(({excludedAttrs}) => { // @TODO: we need a concept for making a diff between incoming authors and existing authors diff --git a/core/server/models/role.js b/core/server/models/role.js index 0bcdb3ee8f..9957cbf064 100644 --- a/core/server/models/role.js +++ b/core/server/models/role.js @@ -50,7 +50,7 @@ Role = ghostBookshelf.Model.extend({ return options; }, - permissible: function permissible(roleModelOrId, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasApiKeyPermission) { + permissible: function permissible(roleModelOrId, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasAppPermission, hasApiKeyPermission) { // If we passed in an id instead of a model, get the model // then check the permissions if (_.isNumber(roleModelOrId) || _.isString(roleModelOrId)) { @@ -95,7 +95,7 @@ Role = ghostBookshelf.Model.extend({ } } - if (hasUserPermission && hasApiKeyPermission) { + if (hasUserPermission && hasAppPermission && hasApiKeyPermission) { return Promise.resolve(); } diff --git a/core/server/models/settings.js b/core/server/models/settings.js index d7245df1e0..936e47b294 100644 --- a/core/server/models/settings.js +++ b/core/server/models/settings.js @@ -251,7 +251,7 @@ Settings = ghostBookshelf.Model.extend({ }); }, - permissible: function permissible(modelId, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasApiKeyPermission) { + permissible: function permissible(modelId, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasAppPermission, hasApiKeyPermission) { let isEdit = (action === 'edit'); let isOwner; @@ -271,7 +271,7 @@ Settings = ghostBookshelf.Model.extend({ hasUserPermission = isOwner; } - if (hasUserPermission && hasApiKeyPermission) { + if (hasUserPermission && hasApiKeyPermission && hasAppPermission) { return Promise.resolve(); } diff --git a/core/server/models/user.js b/core/server/models/user.js index 6a8a27ea10..465023677d 100644 --- a/core/server/models/user.js +++ b/core/server/models/user.js @@ -648,7 +648,7 @@ User = ghostBookshelf.Model.extend({ }); }, - permissible: function permissible(userModelOrId, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasApiKeyPermission) { + permissible: function permissible(userModelOrId, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasAppPermission, hasApiKeyPermission) { var self = this, userModel = userModelOrId, origArgs; @@ -738,7 +738,7 @@ User = ghostBookshelf.Model.extend({ .then((owner) => { // CASE: owner can assign role to any user if (context.user === owner.id) { - if (hasUserPermission && hasApiKeyPermission) { + if (hasUserPermission && hasApiKeyPermission && hasAppPermission) { return Promise.resolve(); } @@ -760,7 +760,7 @@ User = ghostBookshelf.Model.extend({ // e.g. admin can assign admin role to a user, but not owner return permissions.canThis(context).assign.role(role) .then(() => { - if (hasUserPermission && hasApiKeyPermission) { + if (hasUserPermission && hasApiKeyPermission && hasAppPermission) { return Promise.resolve(); } @@ -770,7 +770,7 @@ User = ghostBookshelf.Model.extend({ }); } - if (hasUserPermission && hasApiKeyPermission) { + if (hasUserPermission && hasApiKeyPermission && hasAppPermission) { return Promise.resolve(); } @@ -780,7 +780,7 @@ User = ghostBookshelf.Model.extend({ }); } - if (hasUserPermission && hasApiKeyPermission) { + if (hasUserPermission && hasApiKeyPermission && hasAppPermission) { return Promise.resolve(); } diff --git a/core/server/services/apps/index.js b/core/server/services/apps/index.js new file mode 100644 index 0000000000..8fee45a8b0 --- /dev/null +++ b/core/server/services/apps/index.js @@ -0,0 +1,21 @@ +const debug = require('ghost-ignition').debug('services:apps'); +const Promise = require('bluebird'); +const common = require('../../lib/common'); +const config = require('../../config'); +const loader = require('./loader'); + +module.exports = { + init: function () { + debug('init begin'); + const appsToLoad = config.get('apps:internal'); + + return Promise.map(appsToLoad, appName => loader.activateAppByName(appName)) + .catch(function (err) { + common.logging.error(new common.errors.GhostError({ + err: err, + context: common.i18n.t('errors.apps.appWillNotBeLoaded.error'), + help: common.i18n.t('errors.apps.appWillNotBeLoaded.help') + })); + }); + } +}; diff --git a/core/server/services/apps/loader.js b/core/server/services/apps/loader.js new file mode 100644 index 0000000000..0f323a76e0 --- /dev/null +++ b/core/server/services/apps/loader.js @@ -0,0 +1,45 @@ +const path = require('path'); +const _ = require('lodash'); +const Promise = require('bluebird'); +const common = require('../../lib/common'); +const config = require('../../config'); +const Proxy = require('./proxy'); + +// Get the full path to an app by name +function getAppAbsolutePath(name) { + return path.join(config.get('paths').internalAppPath, name); +} + +function loadApp(name) { + return require(getAppAbsolutePath(name)); +} + +function getAppByName(name) { + // Grab the app class to instantiate + const AppClass = loadApp(name); + const proxy = Proxy.getInstance(); + + // Check for an actual class, otherwise just use whatever was returned + const app = _.isFunction(AppClass) ? new AppClass(proxy) : AppClass; + + return { + app, + proxy + }; +} + +module.exports = { + // Activate a app and return it + activateAppByName: function (name) { + const {app, proxy} = getAppByName(name); + + // Check for an activate() method on the app. + if (!_.isFunction(app.activate)) { + return Promise.reject(new Error(common.i18n.t('errors.apps.noActivateMethodLoadingApp.error', {name: name}))); + } + + // Wrapping the activate() with a when because it's possible + // to not return a promise from it. + return Promise.resolve(app.activate(proxy)).return(app); + } +}; diff --git a/core/server/services/apps/proxy.js b/core/server/services/apps/proxy.js new file mode 100644 index 0000000000..19a3796901 --- /dev/null +++ b/core/server/services/apps/proxy.js @@ -0,0 +1,18 @@ +const helpers = require('../../../frontend/helpers/register'); +const routingService = require('../../../frontend/services/routing'); + +module.exports.getInstance = function getInstance() { + const appRouter = routingService.registry.getRouter('appRouter'); + + return { + helpers: { + register: helpers.registerThemeHelper.bind(helpers), + registerAsync: helpers.registerAsyncThemeHelper.bind(helpers) + }, + // Expose the route service... + routeService: { + // This allows for mounting an entirely new Router at a path... + registerRouter: appRouter.mountRouter.bind(appRouter) + } + }; +}; diff --git a/core/server/services/permissions/can-this.js b/core/server/services/permissions/can-this.js index 981bcc41e7..eab3f940c2 100644 --- a/core/server/services/permissions/can-this.js +++ b/core/server/services/permissions/can-this.js @@ -50,8 +50,10 @@ CanThisResult.prototype.buildObjectTypeHandlers = function (objTypes, actType, c // Iterate through the user permissions looking for an affirmation var userPermissions = loadedPermissions.user ? loadedPermissions.user.permissions : null, apiKeyPermissions = loadedPermissions.apiKey ? loadedPermissions.apiKey.permissions : null, + appPermissions = loadedPermissions.app ? loadedPermissions.app.permissions : null, hasUserPermission, hasApiKeyPermission, + hasAppPermission, checkPermission = function (perm) { var permObjId; @@ -89,14 +91,20 @@ CanThisResult.prototype.buildObjectTypeHandlers = function (objTypes, actType, c hasApiKeyPermission = _.some(apiKeyPermissions, checkPermission); } + // Check app permissions if they were passed + hasAppPermission = true; + if (!_.isNull(appPermissions)) { + hasAppPermission = _.some(appPermissions, checkPermission); + } + // Offer a chance for the TargetModel to override the results if (TargetModel && _.isFunction(TargetModel.permissible)) { return TargetModel.permissible( - modelId, actType, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasApiKeyPermission + modelId, actType, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasAppPermission, hasApiKeyPermission ); } - if (hasUserPermission && hasApiKeyPermission) { + if (hasUserPermission && hasApiKeyPermission && hasAppPermission) { return; } @@ -112,6 +120,7 @@ CanThisResult.prototype.beginCheck = function (context) { var self = this, userPermissionLoad, apiKeyPermissionLoad, + appPermissionLoad, permissionsLoad; // Get context.user, context.api_key and context.app @@ -137,11 +146,20 @@ CanThisResult.prototype.beginCheck = function (context) { apiKeyPermissionLoad = Promise.resolve(null); } + // Kick off loading of app permissions if necessary + if (context.app) { + appPermissionLoad = providers.app(context.app); + } else { + // Resolve null if no context.app + appPermissionLoad = Promise.resolve(null); + } + // Wait for both user and app permissions to load - permissionsLoad = Promise.all([userPermissionLoad, apiKeyPermissionLoad]).then(function (result) { + permissionsLoad = Promise.all([userPermissionLoad, apiKeyPermissionLoad, appPermissionLoad]).then(function (result) { return { user: result[0], - apiKey: result[1] + apiKey: result[1], + app: result[2] }; }); diff --git a/core/server/services/permissions/parse-context.js b/core/server/services/permissions/parse-context.js index fbde6600d8..14315c0851 100644 --- a/core/server/services/permissions/parse-context.js +++ b/core/server/services/permissions/parse-context.js @@ -3,14 +3,16 @@ * * Utility function, to expand strings out into objects. * @param {Object|String} context - * @return {{internal: boolean, external: boolean, user: integer|null, public: boolean, api_key: Object|null}} + * @return {{internal: boolean, external: boolean, user: integer|null, app: integer|null, public: boolean, api_key: Object|null}} */ module.exports = function parseContext(context) { + // Parse what's passed to canThis.beginCheck for standard user and app scopes var parsed = { internal: false, external: false, user: null, api_key: null, + app: null, integration: null, public: true }; @@ -37,5 +39,10 @@ module.exports = function parseContext(context) { parsed.public = (context.api_key.type === 'content'); } + if (context && context.app) { + parsed.app = context.app; + parsed.public = false; + } + return parsed; }; diff --git a/core/server/services/permissions/providers.js b/core/server/services/permissions/providers.js index 152d61d705..04a33a81a4 100644 --- a/core/server/services/permissions/providers.js +++ b/core/server/services/permissions/providers.js @@ -44,6 +44,17 @@ module.exports = { }); }, + app: function (appName) { + return models.App.findOne({name: appName}, {withRelated: ['permissions']}) + .then(function (foundApp) { + if (!foundApp) { + return []; + } + + return {permissions: foundApp.related('permissions').models}; + }); + }, + apiKey(id) { return models.ApiKey.findOne({id}, {withRelated: ['role', 'role.permissions']}) .then((foundApiKey) => { diff --git a/core/server/translations/en.json b/core/server/translations/en.json index ceaeb735b3..613bb01d0e 100644 --- a/core/server/translations/en.json +++ b/core/server/translations/en.json @@ -34,6 +34,18 @@ } }, "errors": { + "apps": { + "appWillNotBeLoaded": { + "error": "The app will not be loaded", + "help": "Check with the app creator, or read the app documentation for more details on app requirements" + }, + "noActivateMethodLoadingApp": { + "error": "Error loading app named {name}; no activate() method defined." + }, + "mustProvideAppName": { + "error": "Must provide an app name for api context" + } + }, "middleware": { "api": { "versionMismatch": "Client request for {clientVersion} does not match server version {serverVersion}." diff --git a/core/server/web/parent-app.js b/core/server/web/parent-app.js index cb8264170c..677bcba6eb 100644 --- a/core/server/web/parent-app.js +++ b/core/server/web/parent-app.js @@ -47,7 +47,7 @@ module.exports = function setupParentApp(options = {}) { // This sets global res.locals which are needed everywhere parentApp.use(shared.middlewares.ghostLocals); - // Mount the express apps on the parentApp + // Mount the apps on the parentApp const adminHost = config.get('admin:url') ? (new URL(config.get('admin:url')).hostname) : ''; const frontendHost = new URL(config.get('url')).hostname; diff --git a/core/server/web/site/app.js b/core/server/web/site/app.js index b4fe91c591..d78bb12c66 100644 --- a/core/server/web/site/app.js +++ b/core/server/web/site/app.js @@ -7,6 +7,7 @@ const common = require('../../lib/common'); // App requires const config = require('../../config'); +const apps = require('../../services/apps'); const constants = require('../../lib/constants'); const storage = require('../../adapters/storage'); const urlService = require('../../../frontend/services/url'); @@ -155,7 +156,7 @@ module.exports = function setupSiteApp(options = {}) { siteApp.use(shared.middlewares.servePublicFile('robots.txt', 'text/plain', constants.ONE_HOUR_S)); // setup middleware for internal apps - // @TODO: refactor this to be a proper app middleware hook for internal apps + // @TODO: refactor this to be a proper app middleware hook for internal & external apps config.get('apps:internal').forEach((appName) => { const app = require(path.join(config.get('paths').internalAppPath, appName)); @@ -210,6 +211,9 @@ module.exports.reload = () => { router = siteRoutes({start: themeService.getApiVersion()}); Object.setPrototypeOf(SiteRouter, router); + // re-initialse apps (register app routers, because we have re-initialised the site routers) + apps.init(); + // connect routers and resources again urlService.queue.start({ event: 'init', diff --git a/core/test/regression/site/site_spec.js b/core/test/regression/site/site_spec.js index 2db16decf9..a347c73d19 100644 --- a/core/test/regression/site/site_spec.js +++ b/core/test/regression/site/site_spec.js @@ -5,6 +5,7 @@ const should = require('should'), testUtils = require('../../utils'), configUtils = require('../../utils/configUtils'), urlUtils = require('../../utils/urlUtils'), + appsService = require('../../../server/services/apps'), frontendSettingsService = require('../../../frontend/services/settings'), themeService = require('../../../frontend/services/themes'), siteApp = require('../../../server/web/parent-app'); @@ -22,7 +23,7 @@ describe('Integration - Web - Site', function () { describe('default routes.yaml', function () { before(function () { testUtils.integrationTesting.urlService.resetGenerators(); - testUtils.integrationTesting.defaultMocks(sinon, {amp: true}); + testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true}); testUtils.integrationTesting.overrideGhostConfig(configUtils); return testUtils.integrationTesting.initGhost() @@ -32,6 +33,9 @@ describe('Integration - Web - Site', function () { app = siteApp({start: true}); return testUtils.integrationTesting.urlService.waitTillFinished(); + }) + .then(() => { + return appsService.init(); }); }); @@ -1717,7 +1721,7 @@ describe('Integration - Web - Site', function () { describe('default routes.yaml', function () { before(function () { testUtils.integrationTesting.urlService.resetGenerators(); - testUtils.integrationTesting.defaultMocks(sinon, {amp: true}); + testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true}); testUtils.integrationTesting.overrideGhostConfig(configUtils); return testUtils.integrationTesting.initGhost() @@ -1727,6 +1731,9 @@ describe('Integration - Web - Site', function () { app = siteApp({start: true}); return testUtils.integrationTesting.urlService.waitTillFinished(); + }) + .then(() => { + return appsService.init(); }); }); @@ -3414,7 +3421,7 @@ describe('Integration - Web - Site', function () { describe('default routes.yaml', function () { before(function () { testUtils.integrationTesting.urlService.resetGenerators(); - testUtils.integrationTesting.defaultMocks(sinon, {amp: true}); + testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true}); testUtils.integrationTesting.overrideGhostConfig(configUtils); return testUtils.integrationTesting.initGhost() @@ -3424,6 +3431,9 @@ describe('Integration - Web - Site', function () { app = siteApp({start: true}); return testUtils.integrationTesting.urlService.waitTillFinished(); + }) + .then(() => { + return appsService.init(); }); }); @@ -5110,7 +5120,7 @@ describe('Integration - Web - Site', function () { describe('no separate admin', function () { before(function () { testUtils.integrationTesting.urlService.resetGenerators(); - testUtils.integrationTesting.defaultMocks(sinon, {amp: true}); + testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true}); testUtils.integrationTesting.overrideGhostConfig(configUtils); configUtils.set('url', 'http://example.com'); @@ -5123,6 +5133,9 @@ describe('Integration - Web - Site', function () { app = siteApp({start: true}); return testUtils.integrationTesting.urlService.waitTillFinished(); + }) + .then(() => { + return appsService.init(); }); }); @@ -5226,7 +5239,7 @@ describe('Integration - Web - Site', function () { describe('separate admin host', function () { before(function () { testUtils.integrationTesting.urlService.resetGenerators(); - testUtils.integrationTesting.defaultMocks(sinon, {amp: true}); + testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true}); testUtils.integrationTesting.overrideGhostConfig(configUtils); configUtils.set('url', 'http://example.com'); @@ -5239,6 +5252,9 @@ describe('Integration - Web - Site', function () { app = siteApp({start: true}); return testUtils.integrationTesting.urlService.waitTillFinished(); + }) + .then(() => { + return appsService.init(); }); }); @@ -5384,7 +5400,7 @@ describe('Integration - Web - Site', function () { describe('separate admin host w/ admin redirects disabled', function () { before(function () { testUtils.integrationTesting.urlService.resetGenerators(); - testUtils.integrationTesting.defaultMocks(sinon, {amp: true}); + testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true}); testUtils.integrationTesting.overrideGhostConfig(configUtils); configUtils.set('url', 'http://example.com'); @@ -5398,6 +5414,9 @@ describe('Integration - Web - Site', function () { app = siteApp({start: true}); return testUtils.integrationTesting.urlService.waitTillFinished(); + }) + .then(() => { + return appsService.init(); }); }); @@ -5429,7 +5448,7 @@ describe('Integration - Web - Site', function () { describe('same host separate protocol', function () { before(function () { testUtils.integrationTesting.urlService.resetGenerators(); - testUtils.integrationTesting.defaultMocks(sinon, {amp: true}); + testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true}); testUtils.integrationTesting.overrideGhostConfig(configUtils); configUtils.set('url', 'http://example.com'); @@ -5442,6 +5461,9 @@ describe('Integration - Web - Site', function () { app = siteApp({start: true}); return testUtils.integrationTesting.urlService.waitTillFinished(); + }) + .then(() => { + return appsService.init(); }); }); diff --git a/core/test/unit/services/apps/proxy_spec.js b/core/test/unit/services/apps/proxy_spec.js new file mode 100644 index 0000000000..07df5e8b39 --- /dev/null +++ b/core/test/unit/services/apps/proxy_spec.js @@ -0,0 +1,36 @@ +const should = require('should'), + sinon = require('sinon'), + helpers = require('../../../../frontend/helpers/register'), + AppProxy = require('../../../../server/services/apps/proxy'), + routing = require('../../../../frontend/services/routing'); + +describe('Apps', function () { + beforeEach(function () { + sinon.stub(routing.registry, 'getRouter').withArgs('appRouter').returns({ + mountRouter: sinon.stub() + }); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('Proxy', function () { + it('creates a ghost proxy', function () { + var appProxy = AppProxy.getInstance('TestApp'); + + should.exist(appProxy.helpers); + should.exist(appProxy.helpers.register); + should.exist(appProxy.helpers.registerAsync); + }); + + it('allows helper registration', function () { + var registerSpy = sinon.stub(helpers, 'registerThemeHelper'), + appProxy = AppProxy.getInstance('TestApp'); + + appProxy.helpers.register('myTestHelper', sinon.stub().returns('test result')); + + registerSpy.called.should.equal(true); + }); + }); +}); diff --git a/core/test/unit/services/permissions/can-this_spec.js b/core/test/unit/services/permissions/can-this_spec.js index a676c769d4..1d5ce4a7fb 100644 --- a/core/test/unit/services/permissions/can-this_spec.js +++ b/core/test/unit/services/permissions/can-this_spec.js @@ -99,7 +99,7 @@ describe('Permissions', function () { canThisResult.destroy.user.should.be.a.Function(); }); - describe('Non user permissions', function () { + describe('Non user/app permissions', function () { // TODO change to using fake models in tests! // Permissions need to be NOT fundamentally baked into Ghost, but a separate module, at some point // It can depend on bookshelf, but should NOT use hard coded model knowledge. @@ -448,6 +448,113 @@ describe('Permissions', function () { .catch(done); }); }); + + describe('App-based permissions (requires user as well)', function () { + // @TODO: revisit this - do we really need to have USER permissions AND app permissions? + it('No permissions: cannot edit tag with app only (no permissible function on model)', function (done) { + var appProviderStub = sinon.stub(providers, 'app').callsFake(function () { + // Fake the response from providers.app, which contains an empty array for this case + return Promise.resolve([]); + }); + + permissions + .canThis({app: {}}) // app context + .edit + .tag({id: 1}) // tag id in model syntax + .then(function () { + done(new Error('was able to edit tag without permission')); + }) + .catch(function (err) { + appProviderStub.callCount.should.eql(1); + err.errorType.should.eql('NoPermissionError'); + done(); + }); + }); + + it('No permissions: cannot edit tag (no permissible function on model)', function (done) { + var appProviderStub = sinon.stub(providers, 'app').callsFake(function () { + // Fake the response from providers.app, which contains an empty array for this case + return Promise.resolve([]); + }), + userProviderStub = sinon.stub(providers, 'user').callsFake(function () { + // Fake the response from providers.user, which contains permissions and roles + return Promise.resolve({ + permissions: [], + roles: undefined + }); + }); + + permissions + .canThis({app: {}, user: {}}) // app context + .edit + .tag({id: 1}) // tag id in model syntax + .then(function () { + done(new Error('was able to edit tag without permission')); + }) + .catch(function (err) { + appProviderStub.callCount.should.eql(1); + userProviderStub.callCount.should.eql(1); + err.errorType.should.eql('NoPermissionError'); + done(); + }); + }); + + it('With permissions: can edit specific tag (no permissible function on model)', function (done) { + var appProviderStub = sinon.stub(providers, 'app').callsFake(function () { + // Fake the response from providers.app, which contains permissions only + return Promise.resolve({ + permissions: models.Permissions.forge(testUtils.DataGenerator.Content.permissions).models + }); + }), + userProviderStub = sinon.stub(providers, 'user').callsFake(function () { + // Fake the response from providers.user, which contains permissions and roles + return Promise.resolve({ + permissions: models.Permissions.forge(testUtils.DataGenerator.Content.permissions).models, + roles: undefined + }); + }); + + permissions + .canThis({app: {}, user: {}}) // app context + .edit + .tag({id: 1}) // tag id in model syntax + .then(function (res) { + appProviderStub.callCount.should.eql(1); + userProviderStub.callCount.should.eql(1); + should.not.exist(res); + done(); + }) + .catch(done); + }); + + it('With permissions: can edit non-specific tag (no permissible function on model)', function (done) { + var appProviderStub = sinon.stub(providers, 'app').callsFake(function () { + // Fake the response from providers.app, which contains permissions only + return Promise.resolve({ + permissions: models.Permissions.forge(testUtils.DataGenerator.Content.permissions).models + }); + }), + userProviderStub = sinon.stub(providers, 'user').callsFake(function () { + // Fake the response from providers.user, which contains permissions and roles + return Promise.resolve({ + permissions: models.Permissions.forge(testUtils.DataGenerator.Content.permissions).models, + roles: undefined + }); + }); + + permissions + .canThis({app: {}, user: {}}) // app context + .edit + .tag() // tag id in model syntax + .then(function (res) { + appProviderStub.callCount.should.eql(1); + userProviderStub.callCount.should.eql(1); + should.not.exist(res); + done(); + }) + .catch(done); + }); + }); }); describe('permissible (overridden)', function () { @@ -472,7 +579,7 @@ describe('Permissions', function () { }) .catch(function (err) { permissibleStub.callCount.should.eql(1); - permissibleStub.firstCall.args.should.have.lengthOf(7); + permissibleStub.firstCall.args.should.have.lengthOf(8); permissibleStub.firstCall.args[0].should.eql(1); permissibleStub.firstCall.args[1].should.eql('edit'); @@ -481,6 +588,7 @@ describe('Permissions', function () { permissibleStub.firstCall.args[4].should.be.an.Object(); permissibleStub.firstCall.args[5].should.be.true(); permissibleStub.firstCall.args[6].should.be.true(); + permissibleStub.firstCall.args[7].should.be.true(); userProviderStub.callCount.should.eql(1); err.message.should.eql('Hello World!'); @@ -506,7 +614,7 @@ describe('Permissions', function () { .post({id: 1}) // tag id in model syntax .then(function (res) { permissibleStub.callCount.should.eql(1); - permissibleStub.firstCall.args.should.have.lengthOf(7); + permissibleStub.firstCall.args.should.have.lengthOf(8); permissibleStub.firstCall.args[0].should.eql(1); permissibleStub.firstCall.args[1].should.eql('edit'); permissibleStub.firstCall.args[2].should.be.an.Object(); @@ -514,6 +622,7 @@ describe('Permissions', function () { permissibleStub.firstCall.args[4].should.be.an.Object(); permissibleStub.firstCall.args[5].should.be.true(); permissibleStub.firstCall.args[6].should.be.true(); + permissibleStub.firstCall.args[7].should.be.true(); userProviderStub.callCount.should.eql(1); should.not.exist(res); diff --git a/core/test/unit/services/permissions/parse-context_spec.js b/core/test/unit/services/permissions/parse-context_spec.js index 5f0320bb7a..a6646e919b 100644 --- a/core/test/unit/services/permissions/parse-context_spec.js +++ b/core/test/unit/services/permissions/parse-context_spec.js @@ -9,6 +9,7 @@ describe('Permissions', function () { external: false, user: null, api_key: null, + app: null, public: true, integration: null }); @@ -17,6 +18,7 @@ describe('Permissions', function () { external: false, user: null, api_key: null, + app: null, public: true, integration: null }); @@ -28,6 +30,7 @@ describe('Permissions', function () { external: false, user: null, api_key: null, + app: null, public: true, integration: null }); @@ -36,6 +39,7 @@ describe('Permissions', function () { external: false, user: null, api_key: null, + app: null, public: true, integration: null }); @@ -47,6 +51,7 @@ describe('Permissions', function () { external: false, user: 1, api_key: null, + app: null, public: false, integration: null }); @@ -64,6 +69,7 @@ describe('Permissions', function () { id: 1, type: 'content' }, + app: null, public: true, integration: {id: 2} }); @@ -81,17 +87,31 @@ describe('Permissions', function () { id: 1, type: 'admin' }, + app: null, public: false, integration: {id: 3} }); }); + it('should return app if app populated', function () { + parseContext({app: 5}).should.eql({ + internal: false, + external: false, + user: null, + api_key: null, + app: 5, + public: false, + integration: null + }); + }); + it('should return internal if internal provided', function () { parseContext({internal: true}).should.eql({ internal: true, external: false, user: null, api_key: null, + app: null, public: false, integration: null }); @@ -101,6 +121,7 @@ describe('Permissions', function () { external: false, user: null, api_key: null, + app: null, public: false, integration: null }); @@ -112,6 +133,7 @@ describe('Permissions', function () { external: true, user: null, api_key: null, + app: null, public: false, integration: null }); @@ -121,6 +143,7 @@ describe('Permissions', function () { external: true, user: null, api_key: null, + app: null, public: false, integration: null }); diff --git a/core/test/unit/services/permissions/providers_spec.js b/core/test/unit/services/permissions/providers_spec.js index 1ebd68bfe1..6181c2f122 100644 --- a/core/test/unit/services/permissions/providers_spec.js +++ b/core/test/unit/services/permissions/providers_spec.js @@ -212,4 +212,60 @@ describe('Permission Providers', function () { }).catch(done); }); }); + + describe('App', function () { + // @TODO make this consistent or sane or something! + // Why is this an empty array, when the success is an object? + // Also why is this an empty array when for users we error?! + it('returns empty array if app cannot be found!', function (done) { + var findAppSpy = sinon.stub(models.App, 'findOne').callsFake(function () { + return Promise.resolve(); + }); + + providers.app('test') + .then(function (res) { + findAppSpy.callCount.should.eql(1); + res.should.be.an.Array().with.lengthOf(0); + done(); + }) + .catch(done); + }); + + it('can load user with role, and permissions', function (done) { + // This test requires quite a lot of unique setup work + var findAppSpy = sinon.stub(models.App, 'findOne').callsFake(function () { + var fakeApp = models.App.forge(testUtils.DataGenerator.Content.apps[0]), + fakePermissions = models.Permissions.forge(testUtils.DataGenerator.Content.permissions); + + // ## Fake the relations + fakeApp.relations = { + permissions: fakePermissions + }; + fakeApp.include = ['permissions']; + + return Promise.resolve(fakeApp); + }); + + // Get permissions for the app + providers.app('kudos') + .then(function (res) { + findAppSpy.callCount.should.eql(1); + + res.should.be.an.Object().with.properties('permissions'); + + res.permissions.should.be.an.Array().with.lengthOf(10); + should.not.exist(res.roles); + + // @TODO fix this! + // Permissions is an array of models + // Roles is a JSON array + res.permissions[0].should.be.an.Object().with.properties('attributes', 'id'); + res.permissions[0].should.be.instanceOf(models.Base.Model); + + done(); + }) + .catch(done); + }); + }); }); + diff --git a/core/test/unit/services/permissions/public_spec.js b/core/test/unit/services/permissions/public_spec.js index 2e79408f29..ff733a3618 100644 --- a/core/test/unit/services/permissions/public_spec.js +++ b/core/test/unit/services/permissions/public_spec.js @@ -13,8 +13,9 @@ describe('Permissions', function () { }); it('should return unchanged object for non-public context', function (done) { - const internal = {context: 'internal'}; - const user = {context: {user: 1}}; + var internal = {context: 'internal'}, + user = {context: {user: 1}}, + app = {context: {app: 1}}; applyPublicRules('posts', 'browse', _.cloneDeep(internal)).then(function (result) { result.should.eql(internal); @@ -23,6 +24,10 @@ describe('Permissions', function () { }).then(function (result) { result.should.eql(user); + return applyPublicRules('posts', 'browse', _.cloneDeep(app)); + }).then(function (result) { + result.should.eql(app); + done(); }).catch(done); }); diff --git a/core/test/utils/fixtures/app/badinstall.js b/core/test/utils/fixtures/app/badinstall.js new file mode 100644 index 0000000000..6bf02a68c3 --- /dev/null +++ b/core/test/utils/fixtures/app/badinstall.js @@ -0,0 +1,14 @@ +function BadApp(app) { + this.app = app; +} + +BadApp.prototype.install = function () { + var knex = require('knex'); + + return knex.dropTableIfExists('users'); +}; + +BadApp.prototype.activate = function () { +}; + +module.exports = BadApp; diff --git a/core/test/utils/fixtures/app/badlib.js b/core/test/utils/fixtures/app/badlib.js new file mode 100644 index 0000000000..065ca48853 --- /dev/null +++ b/core/test/utils/fixtures/app/badlib.js @@ -0,0 +1,5 @@ +var knex = require('knex'); + +module.exports = { + knex: knex +}; diff --git a/core/test/utils/fixtures/app/badoutside.js b/core/test/utils/fixtures/app/badoutside.js new file mode 100644 index 0000000000..fb91276630 --- /dev/null +++ b/core/test/utils/fixtures/app/badoutside.js @@ -0,0 +1,14 @@ +var lib = require('../example'); + +function BadApp(app) { + this.app = app; +} + +BadApp.prototype.install = function () { + return lib.answer; +}; + +BadApp.prototype.activate = function () { +}; + +module.exports = BadApp; diff --git a/core/test/utils/fixtures/app/badrequire.js b/core/test/utils/fixtures/app/badrequire.js new file mode 100644 index 0000000000..70a433668a --- /dev/null +++ b/core/test/utils/fixtures/app/badrequire.js @@ -0,0 +1,14 @@ +var lib = require('./badlib'); + +function BadApp(app) { + this.app = app; +} + +BadApp.prototype.install = function () { + return lib.knex.dropTableIfExists('users'); +}; + +BadApp.prototype.activate = function () { +}; + +module.exports = BadApp; diff --git a/core/test/utils/fixtures/app/badtop.js b/core/test/utils/fixtures/app/badtop.js new file mode 100644 index 0000000000..cb71c209b7 --- /dev/null +++ b/core/test/utils/fixtures/app/badtop.js @@ -0,0 +1,14 @@ +var knex = require('knex'); + +function BadApp(app) { + this.app = app; +} + +BadApp.prototype.install = function () { + return knex.dropTableIfExists('users'); +}; + +BadApp.prototype.activate = function () { +}; + +module.exports = BadApp; diff --git a/core/test/utils/fixtures/app/good.js b/core/test/utils/fixtures/app/good.js new file mode 100644 index 0000000000..116138fda1 --- /dev/null +++ b/core/test/utils/fixtures/app/good.js @@ -0,0 +1,22 @@ +var path = require('path'), + util = require('./goodlib.js'), + nested = require('./nested/goodnested'); + +function GoodApp(app) { + this.app = app; +} + +GoodApp.prototype.install = function () { + // Goes through app to do data + this.app.something = 42; + this.app.util = util; + this.app.nested = nested; + this.app.path = path.join(__dirname, 'good.js'); + + return true; +}; + +GoodApp.prototype.activate = function () { +}; + +module.exports = GoodApp; diff --git a/core/test/utils/fixtures/app/goodlib.js b/core/test/utils/fixtures/app/goodlib.js new file mode 100644 index 0000000000..5ac4e81951 --- /dev/null +++ b/core/test/utils/fixtures/app/goodlib.js @@ -0,0 +1,5 @@ +module.exports = { + util: function () { + return 42; + } +}; diff --git a/core/test/utils/fixtures/app/nested/goodnested.js b/core/test/utils/fixtures/app/nested/goodnested.js new file mode 100644 index 0000000000..1e2ee24549 --- /dev/null +++ b/core/test/utils/fixtures/app/nested/goodnested.js @@ -0,0 +1,5 @@ +var lib = require('../goodlib.js'); + +module.exports = { + other: 42 +}; diff --git a/core/test/utils/fixtures/data-generator.js b/core/test/utils/fixtures/data-generator.js index 1c1676c97a..c98954221e 100644 --- a/core/test/utils/fixtures/data-generator.js +++ b/core/test/utils/fixtures/data-generator.js @@ -296,6 +296,60 @@ DataGenerator.Content = { } ], + apps: [ + { + id: ObjectId.generate(), + name: 'Kudos', + slug: 'kudos', + version: '0.0.1', + status: 'installed' + }, + { + id: ObjectId.generate(), + name: 'Importer', + slug: 'importer', + version: '0.1.0', + status: 'inactive' + }, + { + id: ObjectId.generate(), + name: 'Hemingway', + slug: 'hemingway', + version: '1.0.0', + status: 'installed' + } + ], + + app_fields: [ + { + id: ObjectId.generate(), + key: 'count', + value: '120', + type: 'number', + active: true + }, + { + id: ObjectId.generate(), + key: 'words', + value: '512', + type: 'number', + active: true + } + ], + + app_settings: [ + { + id: ObjectId.generate(), + key: 'color', + value: 'ghosty' + }, + { + id: ObjectId.generate(), + key: 'setting', + value: 'value' + } + ], + subscribers: [ { id: ObjectId.generate(), @@ -565,6 +619,40 @@ DataGenerator.forKnex = (function () { }; } + function createAppField(overrides) { + var newObj = _.cloneDeep(overrides); + + return _.defaults(newObj, { + id: ObjectId.generate(), + created_by: DataGenerator.Content.users[0].id, + created_at: new Date(), + active: true, + app_id: DataGenerator.Content.apps[0].id, + relatable_id: DataGenerator.Content.posts[0].id, + relatable_type: 'posts' + }); + } + + function createAppSetting(overrides) { + var newObj = _.cloneDeep(overrides); + + return _.defaults(newObj, { + id: ObjectId.generate(), + app_id: DataGenerator.Content.apps[0].id, + created_by: DataGenerator.Content.users[0].id, + created_at: new Date() + }); + } + + function createSubscriber(overrides) { + const newObj = _.cloneDeep(overrides); + + return _.defaults(newObj, { + id: ObjectId.generate(), + email: 'subscriber@ghost.org' + }); + } + function createMember(overrides) { const newObj = _.cloneDeep(overrides); @@ -811,6 +899,17 @@ DataGenerator.forKnex = (function () { } ]; + const apps = [ + createBasic(DataGenerator.Content.apps[0]), + createBasic(DataGenerator.Content.apps[1]), + createBasic(DataGenerator.Content.apps[2]) + ]; + + const app_fields = [ + createAppField(DataGenerator.Content.app_fields[0]), + createAppField(DataGenerator.Content.app_fields[1]) + ]; + const invites = [ createInvite({email: 'test1@ghost.org', role_id: DataGenerator.Content.roles[0].id}), createInvite({email: 'test2@ghost.org', role_id: DataGenerator.Content.roles[2].id}) @@ -850,8 +949,12 @@ DataGenerator.forKnex = (function () { createRole: createBasic, createPermission: createBasic, createPostsTags: createPostsTags, + createApp: createBasic, + createAppField: createAppField, createSetting: createSetting, + createAppSetting: createAppSetting, createToken: createToken, + createSubscriber: createSubscriber, createMember: createMember, createInvite: createInvite, createWebhook: createWebhook, @@ -862,6 +965,8 @@ DataGenerator.forKnex = (function () { tags: tags, posts_tags: posts_tags, posts_authors: posts_authors, + apps: apps, + app_fields: app_fields, roles: roles, users: users, roles_users: roles_users, diff --git a/core/test/utils/index.js b/core/test/utils/index.js index f7f8ef7afa..485dd023b8 100644 --- a/core/test/utils/index.js +++ b/core/test/utils/index.js @@ -523,6 +523,21 @@ clearData = function clearData() { }; toDoList = { + app: function insertApp() { + return fixtures.insertOne('App', 'apps', 'createApp'); + }, + app_field: function insertAppField() { + // TODO: use the actual app ID to create the field + return fixtures.insertOne('App', 'apps', 'createApp').then(function () { + return fixtures.insertOne('AppField', 'app_fields', 'createAppField'); + }); + }, + app_setting: function insertAppSetting() { + // TODO: use the actual app ID to create the field + return fixtures.insertOne('App', 'apps', 'createApp').then(function () { + return fixtures.insertOne('AppSetting', 'app_settings', 'createAppSetting'); + }); + }, permission: function insertPermission() { return fixtures.insertOne('Permission', 'permissions', 'createPermission'); }, @@ -535,6 +550,9 @@ toDoList = { tag: function insertTag() { return fixtures.insertOne('Tag', 'tags', 'createTag'); }, + subscriber: function insertSubscriber() { + return fixtures.insertOne('Subscriber', 'subscribers', 'createSubscriber'); + }, member: function insertMember() { return fixtures.insertOne('Member', 'members', 'createMember'); }, @@ -550,6 +568,9 @@ toDoList = { 'tags:extra': function insertExtraTags() { return fixtures.insertExtraTags(); }, + apps: function insertApps() { + return fixtures.insertApps(); + }, settings: function populateSettings() { settingsCache.shutdown(); return settingsService.init(); @@ -1013,6 +1034,10 @@ module.exports = { cacheStub.withArgs('amp').returns(true); } + if (options.apps) { + cacheStub.withArgs('active_apps').returns([]); + } + sandbox.stub(imageLib.imageSize, 'getImageSizeFromUrl').resolves(); },