mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-03 23:00:14 -05:00
feature: theme upload/download/delete (#7209)
refs #7204 - added 3 new themes permissions - change core/client - add theme upload/download logic - extended local file storage to serve zips - added gscan dependency - add ability to handle the express response within the api layer - restrict theme upload to local file storage - added 007 migration
This commit is contained in:
parent
f546a5ce1d
commit
a91e54cf1a
22 changed files with 773 additions and 94 deletions
|
@ -1 +1 @@
|
|||
Subproject commit 1ce2e8b37c931bcc4ac1381f17bc05018282a677
|
||||
Subproject commit efbb0ee9c69b117cf26c84d635e135415ba5649f
|
|
@ -19,6 +19,7 @@ var _ = require('lodash'),
|
|||
clients = require('./clients'),
|
||||
users = require('./users'),
|
||||
slugs = require('./slugs'),
|
||||
themes = require('./themes'),
|
||||
subscribers = require('./subscribers'),
|
||||
authentication = require('./authentication'),
|
||||
uploads = require('./upload'),
|
||||
|
@ -239,6 +240,13 @@ http = function http(apiMethod) {
|
|||
if (res.get('Content-Type') && res.get('Content-Type').indexOf('text/csv') === 0) {
|
||||
return res.status(200).send(response);
|
||||
}
|
||||
|
||||
// CASE: api method response wants to handle the express response
|
||||
// example: serve files (stream)
|
||||
if (_.isFunction(response)) {
|
||||
return response(req, res, next);
|
||||
}
|
||||
|
||||
// Send a properly formatting HTTP response containing the data with correct headers
|
||||
res.json(response || {});
|
||||
}).catch(function onAPIError(error) {
|
||||
|
@ -271,7 +279,8 @@ module.exports = {
|
|||
subscribers: subscribers,
|
||||
authentication: authentication,
|
||||
uploads: uploads,
|
||||
slack: slack
|
||||
slack: slack,
|
||||
themes: themes
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
177
core/server/api/themes.js
Normal file
177
core/server/api/themes.js
Normal file
|
@ -0,0 +1,177 @@
|
|||
// # Themes API
|
||||
// RESTful API for Themes
|
||||
var Promise = require('bluebird'),
|
||||
_ = require('lodash'),
|
||||
gscan = require('gscan'),
|
||||
fs = require('fs-extra'),
|
||||
config = require('../config'),
|
||||
errors = require('../errors'),
|
||||
storage = require('../storage'),
|
||||
settings = require('./settings'),
|
||||
utils = require('./utils'),
|
||||
i18n = require('../i18n'),
|
||||
themes;
|
||||
|
||||
/**
|
||||
* ## Themes API Methods
|
||||
*
|
||||
* **See:** [API Methods](index.js.html#api%20methods)
|
||||
*/
|
||||
themes = {
|
||||
upload: function upload(options) {
|
||||
options = options || {};
|
||||
|
||||
// consistent filename uploads
|
||||
options.originalname = options.originalname.toLowerCase();
|
||||
|
||||
var zip = {
|
||||
path: options.path,
|
||||
name: options.originalname,
|
||||
shortName: options.originalname.split('.zip')[0]
|
||||
}, theme, storageAdapter = storage.getStorage('themes');
|
||||
|
||||
// check if zip name is casper.zip
|
||||
if (zip.name === 'casper.zip') {
|
||||
throw new errors.ValidationError(i18n.t('errors.api.themes.overrideCasper'));
|
||||
}
|
||||
|
||||
return utils.handlePermissions('themes', 'add')(options)
|
||||
.then(function () {
|
||||
return gscan.checkZip(zip, {keepExtractedDir: true});
|
||||
})
|
||||
.then(function (_theme) {
|
||||
theme = _theme;
|
||||
theme = gscan.format(theme);
|
||||
|
||||
if (!theme.results.error.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var validationErrors = [];
|
||||
_.each(theme.results.error, function (error) {
|
||||
if (error.failures) {
|
||||
_.each(error.failures, function (childError) {
|
||||
validationErrors.push(new errors.ValidationError(i18n.t('errors.api.themes.invalidTheme', {
|
||||
reason: childError.ref
|
||||
})));
|
||||
});
|
||||
}
|
||||
|
||||
validationErrors.push(new errors.ValidationError(i18n.t('errors.api.themes.invalidTheme', {
|
||||
reason: error.rule
|
||||
})));
|
||||
});
|
||||
|
||||
throw validationErrors;
|
||||
})
|
||||
.then(function () {
|
||||
return storageAdapter.exists(config.paths.themePath + '/' + zip.shortName);
|
||||
})
|
||||
.then(function (themeExists) {
|
||||
// delete existing theme
|
||||
if (themeExists) {
|
||||
return storageAdapter.delete(zip.shortName, config.paths.themePath);
|
||||
}
|
||||
})
|
||||
.then(function () {
|
||||
return storageAdapter.exists(config.paths.themePath + '/' + zip.name);
|
||||
})
|
||||
.then(function (zipExists) {
|
||||
// delete existing theme zip
|
||||
if (zipExists) {
|
||||
return storageAdapter.delete(zip.name, config.paths.themePath);
|
||||
}
|
||||
})
|
||||
.then(function () {
|
||||
// store extracted theme
|
||||
return storageAdapter.save({
|
||||
name: zip.shortName,
|
||||
path: theme.path
|
||||
}, config.paths.themePath);
|
||||
})
|
||||
.then(function () {
|
||||
// force reload of availableThemes
|
||||
// right now the logic is in the ConfigManager
|
||||
// if we create a theme collection, we don't have to read them from disk
|
||||
return config.loadThemes();
|
||||
})
|
||||
.then(function () {
|
||||
// the settings endpoint is used to fetch the availableThemes
|
||||
// so we have to force updating the in process cache
|
||||
return settings.updateSettingsCache();
|
||||
})
|
||||
.then(function (settings) {
|
||||
// gscan theme structure !== ghost theme structure
|
||||
return {themes: [_.find(settings.availableThemes.value, {name: zip.shortName})]};
|
||||
})
|
||||
.finally(function () {
|
||||
// remove zip upload from multer
|
||||
// happens in background
|
||||
Promise.promisify(fs.removeSync)(zip.path)
|
||||
.catch(function (err) {
|
||||
errors.logError(err);
|
||||
});
|
||||
|
||||
// remove extracted dir from gscan
|
||||
// happens in background
|
||||
if (theme) {
|
||||
Promise.promisify(fs.removeSync)(theme.path)
|
||||
.catch(function (err) {
|
||||
errors.logError(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
download: function download(options) {
|
||||
var themeName = options.name,
|
||||
theme = config.paths.availableThemes[themeName],
|
||||
storageAdapter = storage.getStorage('themes');
|
||||
|
||||
if (!theme) {
|
||||
return Promise.reject(new errors.BadRequestError(i18n.t('errors.api.themes.invalidRequest')));
|
||||
}
|
||||
|
||||
return utils.handlePermissions('themes', 'read')(options)
|
||||
.then(function () {
|
||||
return storageAdapter.serve({isTheme: true, name: themeName});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* remove theme zip
|
||||
* remove theme folder
|
||||
*/
|
||||
destroy: function destroy(options) {
|
||||
var name = options.name,
|
||||
zipName = name + '.zip',
|
||||
theme,
|
||||
storageAdapter = storage.getStorage('themes');
|
||||
|
||||
return utils.handlePermissions('themes', 'destroy')(options)
|
||||
.then(function () {
|
||||
if (name === 'casper') {
|
||||
throw new errors.ValidationError(i18n.t('errors.api.themes.destroyCasper'));
|
||||
}
|
||||
|
||||
theme = config.paths.availableThemes[name];
|
||||
|
||||
if (!theme) {
|
||||
throw new errors.NotFoundError(i18n.t('errors.api.themes.themeDoesNotExist'));
|
||||
}
|
||||
|
||||
return storageAdapter.delete(name, config.paths.themePath);
|
||||
})
|
||||
.then(function () {
|
||||
return storageAdapter.delete(zipName, config.paths.themePath);
|
||||
})
|
||||
.then(function () {
|
||||
return config.loadThemes();
|
||||
})
|
||||
.then(function () {
|
||||
return settings.updateSettingsCache();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = themes;
|
|
@ -78,11 +78,31 @@ ConfigManager.prototype.init = function (rawConfig) {
|
|||
// just the object appropriate for this NODE_ENV
|
||||
self.set(rawConfig);
|
||||
|
||||
return Promise.all([readThemes(self._config.paths.themePath), readDirectory(self._config.paths.appPath)]).then(function (paths) {
|
||||
self._config.paths.availableThemes = paths[0];
|
||||
self._config.paths.availableApps = paths[1];
|
||||
return self._config;
|
||||
});
|
||||
return self.loadThemes()
|
||||
.then(function () {
|
||||
return self.loadApps();
|
||||
})
|
||||
.then(function () {
|
||||
return self._config;
|
||||
});
|
||||
};
|
||||
|
||||
ConfigManager.prototype.loadThemes = function () {
|
||||
var self = this;
|
||||
|
||||
return readThemes(self._config.paths.themePath)
|
||||
.then(function (result) {
|
||||
self._config.paths.availableThemes = result;
|
||||
});
|
||||
};
|
||||
|
||||
ConfigManager.prototype.loadApps = function () {
|
||||
var self = this;
|
||||
|
||||
return readDirectory(self._config.paths.appPath)
|
||||
.then(function (result) {
|
||||
self._config.paths.availableApps = result;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -173,13 +193,13 @@ ConfigManager.prototype.set = function (config) {
|
|||
} else {
|
||||
// ensure there is a default image storage adapter
|
||||
if (!this._config.storage.active.images) {
|
||||
this._config.storage.active.images = defaultSchedulingAdapter;
|
||||
this._config.storage.active.images = defaultStorageAdapter;
|
||||
}
|
||||
|
||||
// ensure there is a default theme storage adapter
|
||||
if (!this._config.storage.active.themes) {
|
||||
this._config.storage.active.themes = defaultSchedulingAdapter;
|
||||
}
|
||||
// @TODO: right now we only support theme uploads to local file storage
|
||||
// @TODO: we need to change reading themes from disk on bootstrap (see loadThemes)
|
||||
this._config.storage.active.themes = defaultStorageAdapter;
|
||||
}
|
||||
|
||||
if (activeSchedulingAdapter === defaultSchedulingAdapter) {
|
||||
|
@ -250,7 +270,7 @@ ConfigManager.prototype.set = function (config) {
|
|||
uploads: {
|
||||
subscribers: {
|
||||
extensions: ['.csv'],
|
||||
contentTypes: ['text/csv','application/csv']
|
||||
contentTypes: ['text/csv', 'application/csv']
|
||||
},
|
||||
images: {
|
||||
extensions: ['.jpg', '.jpeg', '.gif', '.png', '.svg', '.svgz'],
|
||||
|
@ -259,6 +279,10 @@ ConfigManager.prototype.set = function (config) {
|
|||
db: {
|
||||
extensions: ['.json'],
|
||||
contentTypes: ['application/octet-stream', 'application/json']
|
||||
},
|
||||
themes: {
|
||||
extensions: ['.zip'],
|
||||
contentTypes: ['application/zip']
|
||||
}
|
||||
},
|
||||
deprecatedItems: ['updateCheck', 'mail.fromaddress'],
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
var utils = require('../utils'),
|
||||
permissions = require('../../../../permissions'),
|
||||
resource = 'theme';
|
||||
|
||||
function getPermissions() {
|
||||
return utils.findModelFixtures('Permission', {object_type: resource});
|
||||
}
|
||||
|
||||
function getRelations() {
|
||||
return utils.findPermissionRelationsForObject(resource);
|
||||
}
|
||||
|
||||
function printResult(logger, result, message) {
|
||||
if (result.done === result.expected) {
|
||||
logger.info(message);
|
||||
} else {
|
||||
logger.warn('(' + result.done + '/' + result.expected + ') ' + message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = function addThemePermissions(options, logger) {
|
||||
var modelToAdd = getPermissions(),
|
||||
relationToAdd = getRelations();
|
||||
|
||||
return utils.addFixturesForModel(modelToAdd, options).then(function (result) {
|
||||
printResult(logger, result, 'Adding permissions fixtures for ' + resource + 's');
|
||||
return utils.addFixturesForRelation(relationToAdd, options);
|
||||
}).then(function (result) {
|
||||
printResult(logger, result, 'Adding permissions_roles fixtures for ' + resource + 's');
|
||||
}).then(function () {
|
||||
return permissions.init(options);
|
||||
});
|
||||
};
|
3
core/server/data/migration/fixtures/007/index.js
Normal file
3
core/server/data/migration/fixtures/007/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = [
|
||||
require('./01-add-themes-permissions')
|
||||
];
|
|
@ -190,6 +190,21 @@
|
|||
"action_type": "edit",
|
||||
"object_type": "theme"
|
||||
},
|
||||
{
|
||||
"name": "Upload themes",
|
||||
"action_type": "add",
|
||||
"object_type": "theme"
|
||||
},
|
||||
{
|
||||
"name": "Download themes",
|
||||
"action_type": "read",
|
||||
"object_type": "theme"
|
||||
},
|
||||
{
|
||||
"name": "Delete themes",
|
||||
"action_type": "destroy",
|
||||
"object_type": "theme"
|
||||
},
|
||||
{
|
||||
"name": "Browse users",
|
||||
"action_type": "browse",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"core": {
|
||||
"databaseVersion": {
|
||||
"defaultValue": "006"
|
||||
"defaultValue": "007"
|
||||
},
|
||||
"dbHash": {
|
||||
"defaultValue": null
|
||||
|
|
|
@ -273,9 +273,11 @@ canThis = function (context) {
|
|||
return result.beginCheck(context);
|
||||
};
|
||||
|
||||
init = refresh = function () {
|
||||
init = refresh = function (options) {
|
||||
options = options || {};
|
||||
|
||||
// Load all the permissions
|
||||
return Models.Permission.findAll().then(function (perms) {
|
||||
return Models.Permission.findAll(options).then(function (perms) {
|
||||
var seenActions = {};
|
||||
|
||||
exported.actionsMap = {};
|
||||
|
|
|
@ -99,6 +99,24 @@ apiRoutes = function apiRoutes(middleware) {
|
|||
// ## Slugs
|
||||
router.get('/slugs/:type/:name', authenticatePrivate, api.http(api.slugs.generate));
|
||||
|
||||
// ## Themes
|
||||
router.get('/themes/:name/download',
|
||||
authenticatePrivate,
|
||||
api.http(api.themes.download)
|
||||
);
|
||||
|
||||
router.post('/themes/upload',
|
||||
authenticatePrivate,
|
||||
middleware.upload.single('theme'),
|
||||
middleware.validation.upload({type: 'themes'}),
|
||||
api.http(api.themes.upload)
|
||||
);
|
||||
|
||||
router.del('/themes/:name',
|
||||
authenticatePrivate,
|
||||
api.http(api.themes.destroy)
|
||||
);
|
||||
|
||||
// ## Notifications
|
||||
router.get('/notifications', authenticatePrivate, api.http(api.notifications.browse));
|
||||
router.post('/notifications', authenticatePrivate, api.http(api.notifications.add));
|
||||
|
|
|
@ -2,18 +2,20 @@
|
|||
// The (default) module for storing images, using the local file system
|
||||
|
||||
var serveStatic = require('express').static,
|
||||
fs = require('fs-extra'),
|
||||
path = require('path'),
|
||||
util = require('util'),
|
||||
Promise = require('bluebird'),
|
||||
errors = require('../errors'),
|
||||
config = require('../config'),
|
||||
utils = require('../utils'),
|
||||
BaseStore = require('./base');
|
||||
fs = require('fs-extra'),
|
||||
path = require('path'),
|
||||
util = require('util'),
|
||||
Promise = require('bluebird'),
|
||||
execFileAsPromise = Promise.promisify(require('child_process').execFile),
|
||||
errors = require('../errors'),
|
||||
config = require('../config'),
|
||||
utils = require('../utils'),
|
||||
BaseStore = require('./base');
|
||||
|
||||
function LocalFileStore() {
|
||||
BaseStore.call(this);
|
||||
}
|
||||
|
||||
util.inherits(LocalFileStore, BaseStore);
|
||||
|
||||
// ### Save
|
||||
|
@ -33,7 +35,7 @@ LocalFileStore.prototype.save = function (image, targetDir) {
|
|||
// The src for the image must be in URI format, not a file system path, which in Windows uses \
|
||||
// For local file system storage can use relative path so add a slash
|
||||
var fullUrl = (config.paths.subdir + '/' + config.paths.imagesRelPath + '/' +
|
||||
path.relative(config.paths.imagesPath, targetFilename)).replace(new RegExp('\\' + path.sep, 'g'), '/');
|
||||
path.relative(config.paths.imagesPath, targetFilename)).replace(new RegExp('\\' + path.sep, 'g'), '/');
|
||||
return fullUrl;
|
||||
}).catch(function (e) {
|
||||
errors.logError(e);
|
||||
|
@ -51,14 +53,52 @@ LocalFileStore.prototype.exists = function (filename) {
|
|||
};
|
||||
|
||||
// middleware for serving the files
|
||||
LocalFileStore.prototype.serve = function () {
|
||||
// For some reason send divides the max age number by 1000
|
||||
// Fallthrough: false ensures that if an image isn't found, it automatically 404s
|
||||
return serveStatic(config.paths.imagesPath, {maxAge: utils.ONE_YEAR_MS, fallthrough: false});
|
||||
LocalFileStore.prototype.serve = function (options) {
|
||||
var self = this;
|
||||
options = options || {};
|
||||
|
||||
// CASE: serve themes
|
||||
// serveStatic can't be used to serve themes, because
|
||||
// download files depending on the route (see `send` npm module)
|
||||
if (options.isTheme) {
|
||||
return function downloadTheme(req, res, next) {
|
||||
var themeName = options.name,
|
||||
zipName = themeName + '.zip',
|
||||
zipPath = config.paths.themePath + '/' + zipName,
|
||||
stream;
|
||||
|
||||
self.exists(zipPath)
|
||||
.then(function (zipExists) {
|
||||
if (!zipExists) {
|
||||
return execFileAsPromise('zip', ['-r', zipName, themeName], {cwd: config.paths.themePath});
|
||||
}
|
||||
})
|
||||
.then(function () {
|
||||
res.set({
|
||||
'Content-disposition': 'attachment; filename={themeName}.zip'.replace('{themeName}', themeName),
|
||||
'Content-Type': 'application/zip'
|
||||
});
|
||||
|
||||
stream = fs.createReadStream(zipPath);
|
||||
stream.pipe(res);
|
||||
})
|
||||
.catch(function (err) {
|
||||
next(err);
|
||||
});
|
||||
};
|
||||
} else {
|
||||
// CASE: serve images
|
||||
// For some reason send divides the max age number by 1000
|
||||
// Fallthrough: false ensures that if an image isn't found, it automatically 404s
|
||||
return serveStatic(config.paths.imagesPath, {maxAge: utils.ONE_YEAR_MS, fallthrough: false});
|
||||
}
|
||||
};
|
||||
|
||||
LocalFileStore.prototype.delete = function () {
|
||||
return Promise.reject('not implemented');
|
||||
LocalFileStore.prototype.delete = function (fileName, targetDir) {
|
||||
targetDir = targetDir || this.getTargetDir(config.paths.imagesPath);
|
||||
|
||||
var path = targetDir + '/' + fileName;
|
||||
return Promise.promisify(fs.remove)(path);
|
||||
};
|
||||
|
||||
module.exports = LocalFileStore;
|
||||
|
|
|
@ -358,7 +358,11 @@
|
|||
"noPermissionToBrowseThemes": "You do not have permission to browse themes.",
|
||||
"noPermissionToEditThemes": "You do not have permission to edit themes.",
|
||||
"themeDoesNotExist": "Theme does not exist.",
|
||||
"invalidRequest": "Invalid request."
|
||||
"invalidTheme": "Theme is invalid: {reason}",
|
||||
"missingFile": "Please select a theme.",
|
||||
"invalidFile": "Please select a valid zip file.",
|
||||
"overrideCasper": "Please rename your zip, it's not allowed to override the default casper theme.",
|
||||
"destroyCasper": "Deleting the default casper theme is not allowed."
|
||||
},
|
||||
"images": {
|
||||
"missingFile": "Please select an image.",
|
||||
|
|
274
core/test/functional/routes/api/themes_spec.js
Normal file
274
core/test/functional/routes/api/themes_spec.js
Normal file
|
@ -0,0 +1,274 @@
|
|||
var testUtils = require('../../../utils'),
|
||||
should = require('should'),
|
||||
supertest = require('supertest'),
|
||||
fs = require('fs-extra'),
|
||||
path = require('path'),
|
||||
_ = require('lodash'),
|
||||
ghost = require('../../../../../core'),
|
||||
config = require('../../../../../core/server/config'),
|
||||
request;
|
||||
|
||||
describe('Themes API', function () {
|
||||
var scope = {
|
||||
ownerownerAccessToken: '',
|
||||
editorAccessToken: '',
|
||||
uploadTheme: function uploadTheme(options) {
|
||||
var themePath = options.themePath,
|
||||
fieldName = options.fieldName || 'theme',
|
||||
accessToken = options.accessToken || scope.ownerAccessToken;
|
||||
|
||||
return request.post(testUtils.API.getApiQuery('themes/upload'))
|
||||
.set('Authorization', 'Bearer ' + accessToken)
|
||||
.attach(fieldName, themePath);
|
||||
}
|
||||
};
|
||||
|
||||
before(function (done) {
|
||||
ghost().then(function (ghostServer) {
|
||||
request = supertest.agent(ghostServer.rootApp);
|
||||
}).then(function () {
|
||||
return testUtils.doAuth(request, 'perms:theme', 'perms:init', 'users:roles:no-owner');
|
||||
}).then(function (token) {
|
||||
scope.ownerAccessToken = token;
|
||||
|
||||
// 2 === Editor
|
||||
request.userIndex = 2;
|
||||
return testUtils.doAuth(request);
|
||||
}).then(function (token) {
|
||||
scope.editorAccessToken = token;
|
||||
done();
|
||||
}).catch(done);
|
||||
});
|
||||
|
||||
after(function (done) {
|
||||
// clean successful uploaded themes
|
||||
fs.removeSync(config.paths.themePath + '/valid');
|
||||
fs.removeSync(config.paths.themePath + '/casper.zip');
|
||||
|
||||
// gscan creates /test/tmp in test mode
|
||||
fs.removeSync(config.paths.appRoot + '/test');
|
||||
|
||||
testUtils.clearData()
|
||||
.then(function () {
|
||||
done();
|
||||
}).catch(done);
|
||||
});
|
||||
|
||||
describe('success cases', function () {
|
||||
it('get all available themes', function (done) {
|
||||
request.get(testUtils.API.getApiQuery('settings/'))
|
||||
.set('Authorization', 'Bearer ' + scope.ownerAccessToken)
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
var availableThemes = _.find(res.body.settings, {key: 'availableThemes'});
|
||||
should.exist(availableThemes);
|
||||
availableThemes.value.length.should.be.above(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('upload theme', function (done) {
|
||||
scope.uploadTheme({themePath: path.join(__dirname, '/../../../utils/fixtures/themes/valid.zip')})
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
res.statusCode.should.eql(200);
|
||||
should.exist(res.body.themes);
|
||||
res.body.themes.length.should.eql(1);
|
||||
|
||||
should.exist(res.body.themes[0].name);
|
||||
should.exist(res.body.themes[0].package);
|
||||
|
||||
// upload same theme again to force override
|
||||
scope.uploadTheme({themePath: path.join(__dirname, '/../../../utils/fixtures/themes/valid.zip')})
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
// ensure contains two files (zip and extracted theme)
|
||||
fs.readdirSync(config.paths.themePath).join().match(/valid/gi).length.should.eql(1);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('get all available themes', function (done) {
|
||||
request.get(testUtils.API.getApiQuery('settings/'))
|
||||
.set('Authorization', 'Bearer ' + scope.ownerAccessToken)
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
var availableThemes = _.find(res.body.settings, {key: 'availableThemes'});
|
||||
should.exist(availableThemes);
|
||||
|
||||
// ensure the new 'valid' theme is available
|
||||
should.exist(_.find(availableThemes.value, {name: 'valid'}));
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('download theme uuid', function (done) {
|
||||
request.get(testUtils.API.getApiQuery('themes/casper/download/'))
|
||||
.set('Authorization', 'Bearer ' + scope.ownerAccessToken)
|
||||
.expect('Content-Type', /application\/zip/)
|
||||
.expect('Content-Disposition', 'attachment; filename=casper.zip')
|
||||
.expect(200)
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('delete theme uuid', function (done) {
|
||||
request.del(testUtils.API.getApiQuery('themes/valid'))
|
||||
.set('Authorization', 'Bearer ' + scope.ownerAccessToken)
|
||||
.expect(204)
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
fs.existsSync(config.paths.themePath + '/valid').should.eql(false);
|
||||
fs.existsSync(config.paths.themePath + '/valid.zip').should.eql(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error cases', function () {
|
||||
it('upload invalid theme', function (done) {
|
||||
scope.uploadTheme({themePath: path.join(__dirname, '/../../../utils/fixtures/themes/invalid.zip')})
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
res.statusCode.should.eql(422);
|
||||
res.body.errors.length.should.eql(1);
|
||||
res.body.errors[0].message.should.eql('Theme is invalid: A template file called post.hbs must be present.');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('upload casper.zip', function (done) {
|
||||
scope.uploadTheme({themePath: path.join(__dirname, '/../../../utils/fixtures/themes/casper.zip')})
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
res.statusCode.should.eql(422);
|
||||
res.body.errors.length.should.eql(1);
|
||||
res.body.errors[0].message.should.eql('Please rename your zip, it\'s not allowed to override the default casper theme.');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('delete casper', function (done) {
|
||||
request.del(testUtils.API.getApiQuery('themes/casper'))
|
||||
.set('Authorization', 'Bearer ' + scope.ownerAccessToken)
|
||||
.expect(422)
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('delete not existent theme', function (done) {
|
||||
request.del(testUtils.API.getApiQuery('themes/not-existent'))
|
||||
.set('Authorization', 'Bearer ' + scope.ownerAccessToken)
|
||||
.expect(404)
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('upload non application/zip', function (done) {
|
||||
scope.uploadTheme({themePath: path.join(__dirname, '/../../../utils/fixtures/csv/single-column-with-header.csv')})
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
res.statusCode.should.eql(415);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
// @TODO: does not pass in travis with 0.10.x, but local it works
|
||||
it.skip('upload different field name', function (done) {
|
||||
scope.uploadTheme({
|
||||
themePath: path.join(__dirname, '/../../../utils/fixtures/csv/single-column-with-header.csv'),
|
||||
fieldName: 'wrong'
|
||||
}).end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
res.statusCode.should.eql(500);
|
||||
res.body.errors[0].message.should.eql('Unexpected field');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('As Editor', function () {
|
||||
it('no permissions to upload theme', function (done) {
|
||||
scope.uploadTheme({
|
||||
themePath: path.join(__dirname, '/../../../utils/fixtures/themes/valid.zip'),
|
||||
accessToken: scope.editorAccessToken
|
||||
}).end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
res.statusCode.should.eql(403);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('no permissions to delete theme', function (done) {
|
||||
request.del(testUtils.API.getApiQuery('themes/test'))
|
||||
.set('Authorization', 'Bearer ' + scope.editorAccessToken)
|
||||
.expect(403)
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('no permissions to download theme', function (done) {
|
||||
request.get(testUtils.API.getApiQuery('themes/casper/download/'))
|
||||
.set('Authorization', 'Bearer ' + scope.editorAccessToken)
|
||||
.expect(403)
|
||||
.end(function (err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -104,48 +104,54 @@ describe('Database Migration (special functions)', function () {
|
|||
permissions[21].should.be.AssignedToRoles(['Administrator']);
|
||||
permissions[22].name.should.eql('Edit themes');
|
||||
permissions[22].should.be.AssignedToRoles(['Administrator']);
|
||||
permissions[23].name.should.eql('Upload themes');
|
||||
permissions[23].should.be.AssignedToRoles(['Administrator']);
|
||||
permissions[24].name.should.eql('Download themes');
|
||||
permissions[24].should.be.AssignedToRoles(['Administrator']);
|
||||
permissions[25].name.should.eql('Delete themes');
|
||||
permissions[25].should.be.AssignedToRoles(['Administrator']);
|
||||
|
||||
// Users
|
||||
permissions[23].name.should.eql('Browse users');
|
||||
permissions[23].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
permissions[24].name.should.eql('Read users');
|
||||
permissions[24].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
permissions[25].name.should.eql('Edit users');
|
||||
permissions[25].should.be.AssignedToRoles(['Administrator', 'Editor']);
|
||||
permissions[26].name.should.eql('Add users');
|
||||
permissions[26].should.be.AssignedToRoles(['Administrator', 'Editor']);
|
||||
permissions[27].name.should.eql('Delete users');
|
||||
permissions[27].should.be.AssignedToRoles(['Administrator', 'Editor']);
|
||||
permissions[26].name.should.eql('Browse users');
|
||||
permissions[26].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
permissions[27].name.should.eql('Read users');
|
||||
permissions[27].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
permissions[28].name.should.eql('Edit users');
|
||||
permissions[28].should.be.AssignedToRoles(['Administrator', 'Editor']);
|
||||
permissions[29].name.should.eql('Add users');
|
||||
permissions[29].should.be.AssignedToRoles(['Administrator', 'Editor']);
|
||||
permissions[30].name.should.eql('Delete users');
|
||||
permissions[30].should.be.AssignedToRoles(['Administrator', 'Editor']);
|
||||
|
||||
// Roles
|
||||
permissions[28].name.should.eql('Assign a role');
|
||||
permissions[28].should.be.AssignedToRoles(['Administrator', 'Editor']);
|
||||
permissions[29].name.should.eql('Browse roles');
|
||||
permissions[29].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
permissions[31].name.should.eql('Assign a role');
|
||||
permissions[31].should.be.AssignedToRoles(['Administrator', 'Editor']);
|
||||
permissions[32].name.should.eql('Browse roles');
|
||||
permissions[32].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
|
||||
// Clients
|
||||
permissions[30].name.should.eql('Browse clients');
|
||||
permissions[30].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
permissions[31].name.should.eql('Read clients');
|
||||
permissions[31].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
permissions[32].name.should.eql('Edit clients');
|
||||
permissions[32].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
permissions[33].name.should.eql('Add clients');
|
||||
permissions[33].name.should.eql('Browse clients');
|
||||
permissions[33].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
permissions[34].name.should.eql('Delete clients');
|
||||
permissions[34].name.should.eql('Read clients');
|
||||
permissions[34].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
permissions[35].name.should.eql('Edit clients');
|
||||
permissions[35].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
permissions[36].name.should.eql('Add clients');
|
||||
permissions[36].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
permissions[37].name.should.eql('Delete clients');
|
||||
permissions[37].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
|
||||
// Subscribers
|
||||
permissions[35].name.should.eql('Browse subscribers');
|
||||
permissions[35].should.be.AssignedToRoles(['Administrator']);
|
||||
permissions[36].name.should.eql('Read subscribers');
|
||||
permissions[36].should.be.AssignedToRoles(['Administrator']);
|
||||
permissions[37].name.should.eql('Edit subscribers');
|
||||
permissions[37].should.be.AssignedToRoles(['Administrator']);
|
||||
permissions[38].name.should.eql('Add subscribers');
|
||||
permissions[38].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
permissions[39].name.should.eql('Delete subscribers');
|
||||
permissions[38].name.should.eql('Browse subscribers');
|
||||
permissions[38].should.be.AssignedToRoles(['Administrator']);
|
||||
permissions[39].name.should.eql('Read subscribers');
|
||||
permissions[39].should.be.AssignedToRoles(['Administrator']);
|
||||
permissions[40].name.should.eql('Edit subscribers');
|
||||
permissions[40].should.be.AssignedToRoles(['Administrator']);
|
||||
permissions[41].name.should.eql('Add subscribers');
|
||||
permissions[41].should.be.AssignedToRoles(['Administrator', 'Editor', 'Author']);
|
||||
permissions[42].name.should.eql('Delete subscribers');
|
||||
permissions[42].should.be.AssignedToRoles(['Administrator']);
|
||||
});
|
||||
|
||||
describe('Populate', function () {
|
||||
|
@ -206,7 +212,7 @@ describe('Database Migration (special functions)', function () {
|
|||
result.roles.at(3).get('name').should.eql('Owner');
|
||||
|
||||
// Permissions
|
||||
result.permissions.length.should.eql(40);
|
||||
result.permissions.length.should.eql(43);
|
||||
result.permissions.toJSON().should.be.CompletePermissions();
|
||||
|
||||
done();
|
||||
|
|
|
@ -204,7 +204,7 @@ describe('Config', function () {
|
|||
config.storage.should.have.property('s3', {});
|
||||
});
|
||||
|
||||
it('should allow setting a custom active storage as object', function () {
|
||||
it('should use default theme adapter when passing an object', function () {
|
||||
var storagePath = path.join(config.paths.contentPath, 'storage', 's3');
|
||||
|
||||
configUtils.set({
|
||||
|
@ -217,7 +217,7 @@ describe('Config', function () {
|
|||
|
||||
config.storage.should.have.property('active', {
|
||||
images: 'local-file-store',
|
||||
themes: 's3'
|
||||
themes: 'local-file-store'
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -228,14 +228,14 @@ describe('Config', function () {
|
|||
storage: {
|
||||
active: {
|
||||
images: 's2',
|
||||
themes: 's3'
|
||||
themes: 'local-file-store'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
config.storage.should.have.property('active', {
|
||||
images: 's2',
|
||||
themes: 's3'
|
||||
themes: 'local-file-store'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,24 +1,26 @@
|
|||
var should = require('should'),
|
||||
sinon = require('sinon'),
|
||||
_ = require('lodash'),
|
||||
moment = require('moment'),
|
||||
rewire = require('rewire'),
|
||||
var should = require('should'),
|
||||
sinon = require('sinon'),
|
||||
_ = require('lodash'),
|
||||
moment = require('moment'),
|
||||
rewire = require('rewire'),
|
||||
Promise = require('bluebird'),
|
||||
|
||||
// Stuff we are testing
|
||||
configUtils = require('../utils/configUtils'),
|
||||
models = require('../../server/models'),
|
||||
api = require('../../server/api'),
|
||||
configUtils = require('../utils/configUtils'),
|
||||
models = require('../../server/models'),
|
||||
api = require('../../server/api'),
|
||||
permissions = require('../../server/permissions'),
|
||||
notifications = require('../../server/api/notifications'),
|
||||
versioning = require('../../server/data/schema/versioning'),
|
||||
update = rewire('../../server/data/migration/fixtures/update'),
|
||||
populate = rewire('../../server/data/migration/fixtures/populate'),
|
||||
fixtureUtils = require('../../server/data/migration/fixtures/utils'),
|
||||
fixtures004 = require('../../server/data/migration/fixtures/004'),
|
||||
fixtures005 = require('../../server/data/migration/fixtures/005'),
|
||||
fixtures006 = require('../../server/data/migration/fixtures/006'),
|
||||
versioning = require('../../server/data/schema/versioning'),
|
||||
update = rewire('../../server/data/migration/fixtures/update'),
|
||||
populate = rewire('../../server/data/migration/fixtures/populate'),
|
||||
fixtureUtils = require('../../server/data/migration/fixtures/utils'),
|
||||
fixtures004 = require('../../server/data/migration/fixtures/004'),
|
||||
fixtures005 = require('../../server/data/migration/fixtures/005'),
|
||||
fixtures006 = require('../../server/data/migration/fixtures/006'),
|
||||
fixtures007 = require('../../server/data/migration/fixtures/007'),
|
||||
|
||||
sandbox = sinon.sandbox.create();
|
||||
sandbox = sinon.sandbox.create();
|
||||
|
||||
describe('Fixtures', function () {
|
||||
var loggerStub, transactionStub;
|
||||
|
@ -72,7 +74,7 @@ describe('Fixtures', function () {
|
|||
|
||||
sequenceStub.returns(Promise.resolve([]));
|
||||
|
||||
update(tasks, loggerStub, {transacting:transactionStub}).then(function (result) {
|
||||
update(tasks, loggerStub, {transacting: transactionStub}).then(function (result) {
|
||||
should.exist(result);
|
||||
|
||||
loggerStub.info.calledOnce.should.be.true();
|
||||
|
@ -490,7 +492,7 @@ describe('Fixtures', function () {
|
|||
loggerStub.info.calledOnce.should.be.true();
|
||||
// gets called because we're stubbing to return an empty array
|
||||
loggerStub.warn.calledOnce.should.be.true();
|
||||
sinon.assert.callOrder(loggerStub.info, postAllStub, postCollStub.mapThen, postObjStub.load);
|
||||
sinon.assert.callOrder(loggerStub.info, postAllStub, postCollStub.mapThen, postObjStub.load);
|
||||
|
||||
done();
|
||||
}).catch(done);
|
||||
|
@ -932,7 +934,7 @@ describe('Fixtures', function () {
|
|||
|
||||
sequenceStub.returns(Promise.resolve([]));
|
||||
|
||||
update(tasks, loggerStub, {transacting:transactionStub}).then(function (result) {
|
||||
update(tasks, loggerStub, {transacting: transactionStub}).then(function (result) {
|
||||
should.exist(result);
|
||||
|
||||
loggerStub.info.calledOnce.should.be.true();
|
||||
|
@ -1099,6 +1101,77 @@ describe('Fixtures', function () {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update to 007', function () {
|
||||
it('should call all the 007 fixture upgrades', function (done) {
|
||||
// Setup
|
||||
// Create a new stub, this will replace sequence, so that db calls don't actually get run
|
||||
var sequenceStub = sandbox.stub(),
|
||||
sequenceReset = update.__set__('sequence', sequenceStub),
|
||||
tasks = versioning.getUpdateFixturesTasks('007', loggerStub);
|
||||
|
||||
sequenceStub.returns(Promise.resolve([]));
|
||||
|
||||
update(tasks, loggerStub, {transacting: transactionStub}).then(function (result) {
|
||||
should.exist(result);
|
||||
|
||||
loggerStub.info.calledOnce.should.be.true();
|
||||
loggerStub.warn.called.should.be.false();
|
||||
|
||||
sequenceStub.calledOnce.should.be.true();
|
||||
|
||||
sequenceStub.firstCall.calledWith(sinon.match.array, sinon.match.object, loggerStub).should.be.true();
|
||||
sequenceStub.firstCall.args[0].should.be.an.Array().with.lengthOf(1);
|
||||
sequenceStub.firstCall.args[0][0].should.be.a.Function().with.property('name', 'addThemePermissions');
|
||||
|
||||
// Reset
|
||||
sequenceReset();
|
||||
done();
|
||||
}).catch(done);
|
||||
});
|
||||
|
||||
describe('Tasks:', function () {
|
||||
it('should have tasks for 007', function () {
|
||||
should.exist(fixtures007);
|
||||
fixtures007.should.be.an.Array().with.lengthOf(1);
|
||||
});
|
||||
|
||||
describe('01-addThemePermissions', function () {
|
||||
var updateThemePermissions = fixtures007[0], addModelStub, relationResult, addRelationStub, modelResult;
|
||||
|
||||
before(function () {
|
||||
modelResult = {expected: 1, done: 1};
|
||||
addModelStub = sandbox.stub(fixtureUtils, 'addFixturesForModel')
|
||||
.returns(Promise.resolve(modelResult));
|
||||
|
||||
relationResult = {expected: 1, done: 1};
|
||||
addRelationStub = sandbox.stub(fixtureUtils, 'addFixturesForRelation')
|
||||
.returns(Promise.resolve(relationResult));
|
||||
|
||||
sandbox.stub(permissions, 'init').returns(Promise.resolve());
|
||||
});
|
||||
|
||||
it('ensure permissions get updates', function (done) {
|
||||
updateThemePermissions({context: {internal: true}}, loggerStub)
|
||||
.then(function () {
|
||||
addModelStub.calledOnce.should.be.true();
|
||||
addModelStub.calledWith(
|
||||
fixtureUtils.findModelFixtures('Permission', {object_type: 'theme'})
|
||||
).should.be.true();
|
||||
|
||||
addRelationStub.calledOnce.should.be.true();
|
||||
addRelationStub.calledWith(
|
||||
fixtureUtils.findPermissionRelationsForObject('theme')
|
||||
).should.be.true();
|
||||
|
||||
permissions.init.calledOnce.should.eql(true);
|
||||
done();
|
||||
})
|
||||
.catch(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Populate fixtures', function () {
|
||||
|
@ -1111,14 +1184,14 @@ describe('Fixtures', function () {
|
|||
clientAddStub = sandbox.stub(models.Client, 'add').returns(Promise.resolve()),
|
||||
permsAddStub = sandbox.stub(models.Permission, 'add').returns(Promise.resolve()),
|
||||
|
||||
// Existence checks
|
||||
// Existence checks
|
||||
postOneStub = sandbox.stub(models.Post, 'findOne').returns(Promise.resolve()),
|
||||
tagOneStub = sandbox.stub(models.Tag, 'findOne').returns(Promise.resolve()),
|
||||
roleOneStub = sandbox.stub(models.Role, 'findOne').returns(Promise.resolve()),
|
||||
clientOneStub = sandbox.stub(models.Client, 'findOne').returns(Promise.resolve()),
|
||||
permOneStub = sandbox.stub(models.Permission, 'findOne').returns(Promise.resolve()),
|
||||
|
||||
// Relations
|
||||
// Relations
|
||||
fromItem = {
|
||||
related: sandbox.stub().returnsThis(),
|
||||
findWhere: sandbox.stub().returns({})
|
||||
|
@ -1130,7 +1203,7 @@ describe('Fixtures', function () {
|
|||
postsAllStub = sandbox.stub(models.Post, 'findAll').returns(Promise.resolve(modelMethodStub)),
|
||||
tagsAllStub = sandbox.stub(models.Tag, 'findAll').returns(Promise.resolve(modelMethodStub)),
|
||||
|
||||
// Create Owner
|
||||
// Create Owner
|
||||
userAddStub = sandbox.stub(models.User, 'add').returns(Promise.resolve({}));
|
||||
roleOneStub.onCall(4).returns(Promise.resolve({id: 1}));
|
||||
|
||||
|
@ -1147,9 +1220,9 @@ describe('Fixtures', function () {
|
|||
clientOneStub.calledThrice.should.be.true();
|
||||
clientAddStub.calledThrice.should.be.true();
|
||||
|
||||
permOneStub.callCount.should.eql(40);
|
||||
permOneStub.callCount.should.eql(43);
|
||||
permsAddStub.called.should.be.true();
|
||||
permsAddStub.callCount.should.eql(40);
|
||||
permsAddStub.callCount.should.eql(43);
|
||||
|
||||
permsAllStub.calledOnce.should.be.true();
|
||||
rolesAllStub.calledOnce.should.be.true();
|
||||
|
|
|
@ -31,9 +31,9 @@ var should = require('should'),
|
|||
// both of which are required for migrations to work properly.
|
||||
describe('DB version integrity', function () {
|
||||
// Only these variables should need updating
|
||||
var currentDbVersion = '006',
|
||||
var currentDbVersion = '007',
|
||||
currentSchemaHash = 'f63f41ac97b5665a30c899409bbf9a83',
|
||||
currentFixturesHash = '56f781fa3bba0fdbf98da5f232ec9b11';
|
||||
currentFixturesHash = '30b0a956b04e634e7f2cddcae8d2fd20';
|
||||
|
||||
// If this test is failing, then it is likely a change has been made that requires a DB version bump,
|
||||
// and the values above will need updating as confirmation
|
||||
|
|
|
@ -108,7 +108,7 @@ describe('server bootstrap', function () {
|
|||
|
||||
migration.update.execute.calledWith({
|
||||
fromVersion: '006',
|
||||
toVersion: '006',
|
||||
toVersion: '007',
|
||||
forceMigration: undefined
|
||||
}).should.eql(true);
|
||||
|
||||
|
|
BIN
core/test/utils/fixtures/themes/casper.zip
Normal file
BIN
core/test/utils/fixtures/themes/casper.zip
Normal file
Binary file not shown.
BIN
core/test/utils/fixtures/themes/invalid.zip
Normal file
BIN
core/test/utils/fixtures/themes/invalid.zip
Normal file
Binary file not shown.
BIN
core/test/utils/fixtures/themes/valid.zip
Normal file
BIN
core/test/utils/fixtures/themes/valid.zip
Normal file
Binary file not shown.
|
@ -45,6 +45,7 @@
|
|||
"fs-extra": "0.30.0",
|
||||
"ghost-gql": "0.0.5",
|
||||
"glob": "5.0.15",
|
||||
"gscan": "0.0.9",
|
||||
"html-to-text": "2.1.3",
|
||||
"image-size": "0.5.0",
|
||||
"intl": "1.2.4",
|
||||
|
|
Loading…
Add table
Reference in a new issue