Revert "Remove Apps"

This reverts commit cbb59a57db.
This commit is contained in:
Hannah Wolfe 2020-03-20 08:58:26 +00:00
parent cbb59a57db
commit bc7906a7b2
46 changed files with 798 additions and 58 deletions

View File

@ -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');

View File

@ -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}`);

View File

@ -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 = {

View File

@ -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 = {

View File

@ -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'});

View File

@ -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},

View File

@ -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')) {

View File

@ -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];
}

View File

@ -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.
*/

View File

@ -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)
};

View File

@ -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)
};

60
core/server/models/app.js Normal file
View File

@ -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)
};

View File

@ -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'),

View File

@ -15,6 +15,9 @@ require('./base/listeners');
exports = module.exports;
models = [
'app-field',
'app-setting',
'app',
'permission',
'post',
'role',

View File

@ -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();
}

View File

@ -33,6 +33,10 @@ Permission = ghostBookshelf.Model.extend({
users: function users() {
return this.belongsToMany('User');
},
apps: function apps() {
return this.belongsToMany('App');
}
});

View File

@ -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});
}

View File

@ -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

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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')
}));
});
}
};

View File

@ -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);
}
};

View File

@ -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)
}
};
};

View File

@ -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]
};
});

View File

@ -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;
};

View File

@ -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) => {

View File

@ -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}."

View File

@ -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;

View File

@ -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',

View File

@ -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();
});
});

View File

@ -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);
});
});
});

View File

@ -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);

View File

@ -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
});

View File

@ -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);
});
});
});

View File

@ -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);
});

View File

@ -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;

View File

@ -0,0 +1,5 @@
var knex = require('knex');
module.exports = {
knex: knex
};

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,5 @@
module.exports = {
util: function () {
return 42;
}
};

View File

@ -0,0 +1,5 @@
var lib = require('../goodlib.js');
module.exports = {
other: 42
};

View File

@ -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,

View File

@ -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();
},