Ghost/core/server/api/themes.js
Hannah Wolfe b2f1d0559b Themes API activation permissions & validation (#8104)
refs #8093

 Add activate theme permission
- add permission to activate themes
- update tests
- also: update tests for invites
TODO: change how the active theme setting is updated to reduce extra permissions

 Move theme validation to gscan
- add a new gscan validation method and use it for upload
- update activate endpoint to do validation also using gscan
- change to using SettingsModel instead of API so that we don't call validation or permissions on the settings API
- remove validation from the settings model
- remove the old validation function
- add new invalid theme message to translations & remove a bunch of theme validation related unused keys

📖  Planned changes

🚨 Tests for theme activation API endpoint
🐛 Don't allow deleting the active theme

🚫 Prevent activeTheme being set via settings API
- We want to control how this happens in future.
- We still want to store the information in settings, via the model.
- We just don't want to be able to change this info via the settings edit endpoint

🐛  Fix warnings for uploads & add for activations
- warnings for uploads were broken in f8b498d
- fix the response + adds tests to cover that warnings are correctly returned
- add the same response to activations + more tests
- activations now return a single theme object - the theme that was activated + any warnings

🎨 Improve how we generate theme API responses
- remove the requirement to pass in the active theme!
- move this to a specialist function, away from the list

🎨 Do not load gscan on boot
2017-03-13 12:44:44 +01:00

187 lines
6.7 KiB
JavaScript

// # Themes API
// RESTful API for Themes
var Promise = require('bluebird'),
fs = require('fs-extra'),
config = require('../config'),
errors = require('../errors'),
events = require('../events'),
logging = require('../logging'),
storage = require('../storage'),
apiUtils = require('./utils'),
utils = require('./../utils'),
i18n = require('../i18n'),
settingsModel = require('../models/settings').Settings,
settingsCache = require('../settings/cache'),
themeUtils = require('../themes'),
themeList = themeUtils.list,
themes;
/**
* ## Themes API Methods
*
* **See:** [API Methods](index.js.html#api%20methods)
*/
themes = {
browse: function browse() {
return Promise.resolve(themeUtils.toJSON());
},
activate: function activate(options) {
var themeName = options.name,
newSettings = [{
key: 'activeTheme',
value: themeName
}],
loadedTheme,
checkedTheme;
return apiUtils
.handlePermissions('themes', 'activate')(options)
.then(function activateTheme() {
loadedTheme = themeList.get(themeName);
if (!loadedTheme) {
return Promise.reject(new errors.ValidationError({
message: i18n.t('notices.data.validation.index.themeCannotBeActivated', {themeName: themeName}),
context: 'activeTheme'
}));
}
return themeUtils.validate.check(loadedTheme);
})
.then(function haveValidTheme(_checkedTheme) {
checkedTheme = _checkedTheme;
// We use the model, not the API here, as we don't want to trigger permissions
return settingsModel.edit(newSettings, options);
})
.then(function hasEditedSetting() {
// @TODO actually do things to activate the theme, other than just the setting?
return themeUtils.toJSON(themeName, checkedTheme);
});
},
upload: function upload(options) {
options = options || {};
// consistent filename uploads
options.originalname = options.originalname.toLowerCase();
var storageAdapter = storage.getStorage('themes'),
zip = {
path: options.path,
name: options.originalname,
shortName: storageAdapter.getSanitizedFileName(options.originalname.split('.zip')[0])
},
checkedTheme;
// check if zip name is casper.zip
if (zip.name === 'casper.zip') {
throw new errors.ValidationError({message: i18n.t('errors.api.themes.overrideCasper')});
}
return apiUtils.handlePermissions('themes', 'add')(options)
.then(function validateTheme() {
return themeUtils.validate.check(zip, true);
})
.then(function checkExists(_checkedTheme) {
checkedTheme = _checkedTheme;
return storageAdapter.exists(utils.url.urlJoin(config.getContentPath('themes'), zip.shortName));
})
.then(function (themeExists) {
// delete existing theme
if (themeExists) {
return storageAdapter.delete(zip.shortName, config.getContentPath('themes'));
}
})
.then(function () {
events.emit('theme.uploaded', zip.shortName);
// store extracted theme
return storageAdapter.save({
name: zip.shortName,
path: checkedTheme.path
}, config.getContentPath('themes'));
})
.then(function () {
// Loads the theme from the filesystem
// Sets the theme on the themeList
return themeUtils.loadOne(zip.shortName);
})
.then(function () {
// @TODO: unify the name across gscan and Ghost!
return themeUtils.toJSON(zip.shortName, checkedTheme);
})
.finally(function () {
// @TODO we should probably do this as part of saving the theme
// remove zip upload from multer
// happens in background
Promise.promisify(fs.removeSync)(zip.path)
.catch(function (err) {
logging.error(new errors.GhostError({err: err}));
});
// @TODO we should probably do this as part of saving the theme
// remove extracted dir from gscan
// happens in background
if (checkedTheme) {
Promise.promisify(fs.removeSync)(checkedTheme.path)
.catch(function (err) {
logging.error(new errors.GhostError({err: err}));
});
}
});
},
download: function download(options) {
var themeName = options.name,
theme = themeList.get(themeName),
storageAdapter = storage.getStorage('themes');
if (!theme) {
return Promise.reject(new errors.BadRequestError({message: i18n.t('errors.api.themes.invalidRequest')}));
}
return apiUtils.handlePermissions('themes', 'read')(options)
.then(function () {
events.emit('theme.downloaded', themeName);
return storageAdapter.serve({isTheme: true, name: themeName});
});
},
/**
* remove theme zip
* remove theme folder
*/
destroy: function destroy(options) {
var name = options.name,
theme,
storageAdapter = storage.getStorage('themes');
return apiUtils.handlePermissions('themes', 'destroy')(options)
.then(function () {
if (name === 'casper') {
throw new errors.ValidationError({message: i18n.t('errors.api.themes.destroyCasper')});
}
if (name === settingsCache.get('activeTheme')) {
throw new errors.ValidationError({message: i18n.t('errors.api.themes.destroyActive')});
}
theme = themeList.get(name);
if (!theme) {
throw new errors.NotFoundError({message: i18n.t('errors.api.themes.themeDoesNotExist')});
}
return storageAdapter.delete(name, config.getContentPath('themes'));
})
.then(function () {
themeList.del(name);
events.emit('theme.deleted', name);
// Delete returns an empty 204 response
});
}
};
module.exports = themes;