mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
parent
7d05cbba1d
commit
eafbaaeba5
7 changed files with 545 additions and 5 deletions
|
@ -1,4 +1,5 @@
|
|||
const debug = require('ghost-ignition').debug('api:shared:headers');
|
||||
const Promise = require('bluebird');
|
||||
const INVALIDATE_ALL = '/*';
|
||||
|
||||
const cacheInvalidate = (result, options = {}) => {
|
||||
|
|
|
@ -105,5 +105,9 @@ module.exports = {
|
|||
|
||||
get publicSettings() {
|
||||
return shared.pipeline(require('./settings-public'), localUtils);
|
||||
},
|
||||
|
||||
get themes() {
|
||||
return shared.pipeline(require('./themes'), localUtils);
|
||||
}
|
||||
};
|
||||
|
|
217
core/server/api/v2/themes.js
Normal file
217
core/server/api/v2/themes.js
Normal file
|
@ -0,0 +1,217 @@
|
|||
const Promise = require('bluebird');
|
||||
const fs = require('fs-extra');
|
||||
const debug = require('ghost-ignition').debug('api:themes');
|
||||
const common = require('../../lib/common');
|
||||
const themeService = require('../../services/themes');
|
||||
const settingsCache = require('../../services/settings/cache');
|
||||
const models = require('../../models');
|
||||
|
||||
module.exports = {
|
||||
docName: 'themes',
|
||||
|
||||
browse: {
|
||||
permissions: true,
|
||||
query() {
|
||||
return themeService.toJSON();
|
||||
}
|
||||
},
|
||||
|
||||
activate: {
|
||||
headers: {
|
||||
cacheInvalidate: true
|
||||
},
|
||||
options: [
|
||||
'name'
|
||||
],
|
||||
validation: {
|
||||
options: {
|
||||
name: {
|
||||
required: true
|
||||
}
|
||||
}
|
||||
},
|
||||
permissions: true,
|
||||
query(frame) {
|
||||
let themeName = frame.options.name;
|
||||
let checkedTheme;
|
||||
|
||||
const newSettings = [{
|
||||
key: 'active_theme',
|
||||
value: themeName
|
||||
}];
|
||||
|
||||
const loadedTheme = themeService.list.get(themeName);
|
||||
|
||||
if (!loadedTheme) {
|
||||
return Promise.reject(new common.errors.ValidationError({
|
||||
message: common.i18n.t('notices.data.validation.index.themeCannotBeActivated', {themeName: themeName}),
|
||||
context: 'active_theme'
|
||||
}));
|
||||
}
|
||||
|
||||
return themeService.validate.check(loadedTheme)
|
||||
.then((_checkedTheme) => {
|
||||
checkedTheme = _checkedTheme;
|
||||
|
||||
// @NOTE: we use the model, not the API here, as we don't want to trigger permissions
|
||||
return models.Settings.edit(newSettings, frame.options);
|
||||
})
|
||||
.then(() => {
|
||||
debug('Activating theme (method B on API "activate")', themeName);
|
||||
themeService.activate(loadedTheme, checkedTheme);
|
||||
|
||||
return themeService.toJSON(themeName, checkedTheme);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
upload: {
|
||||
headers: {},
|
||||
permissions: {
|
||||
method: 'add'
|
||||
},
|
||||
query(frame) {
|
||||
// @NOTE: consistent filename uploads
|
||||
frame.options.originalname = frame.file.originalname.toLowerCase();
|
||||
|
||||
let zip = {
|
||||
path: frame.file.path,
|
||||
name: frame.file.originalname,
|
||||
shortName: themeService.storage.getSanitizedFileName(frame.file.originalname.split('.zip')[0])
|
||||
};
|
||||
|
||||
let checkedTheme;
|
||||
|
||||
// check if zip name is casper.zip
|
||||
if (zip.name === 'casper.zip') {
|
||||
throw new common.errors.ValidationError({
|
||||
message: common.i18n.t('errors.api.themes.overrideCasper')
|
||||
});
|
||||
}
|
||||
|
||||
return themeService.validate.check(zip, true)
|
||||
.then((_checkedTheme) => {
|
||||
checkedTheme = _checkedTheme;
|
||||
|
||||
return themeService.storage.exists(zip.shortName);
|
||||
})
|
||||
.then((themeExists) => {
|
||||
// CASE: delete existing theme
|
||||
if (themeExists) {
|
||||
return themeService.storage.delete(zip.shortName);
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
// CASE: store extracted theme
|
||||
return themeService.storage.save({
|
||||
name: zip.shortName,
|
||||
path: checkedTheme.path
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
// CASE: loads the theme from the fs & sets the theme on the themeList
|
||||
return themeService.loadOne(zip.shortName);
|
||||
})
|
||||
.then((loadedTheme) => {
|
||||
// CASE: if this is the active theme, we are overriding
|
||||
if (zip.shortName === settingsCache.get('active_theme')) {
|
||||
debug('Activating theme (method C, on API "override")', zip.shortName);
|
||||
themeService.activate(loadedTheme, checkedTheme);
|
||||
|
||||
// CASE: clear cache
|
||||
this.headers.cacheInvalidate = true;
|
||||
}
|
||||
|
||||
// @TODO: unify the name across gscan and Ghost!
|
||||
return themeService.toJSON(zip.shortName, checkedTheme);
|
||||
})
|
||||
.finally(() => {
|
||||
// @TODO: we should probably do this as part of saving the theme
|
||||
// CASE: remove extracted dir from gscan
|
||||
// happens in background
|
||||
if (checkedTheme) {
|
||||
fs.remove(checkedTheme.path)
|
||||
.catch((err) => {
|
||||
common.logging.error(new common.errors.GhostError({err: err}));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
download: {
|
||||
options: [
|
||||
'name'
|
||||
],
|
||||
validation: {
|
||||
options: {
|
||||
name: {
|
||||
required: true
|
||||
}
|
||||
}
|
||||
},
|
||||
permissions: {
|
||||
method: 'read'
|
||||
},
|
||||
query(frame) {
|
||||
let themeName = frame.options.name;
|
||||
const theme = themeService.list.get(themeName);
|
||||
|
||||
if (!theme) {
|
||||
return Promise.reject(new common.errors.BadRequestError({
|
||||
message: common.i18n.t('errors.api.themes.invalidThemeName')
|
||||
}));
|
||||
}
|
||||
|
||||
return themeService.storage.serve({
|
||||
name: themeName
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
destroy: {
|
||||
statusCode: 204,
|
||||
headers: {
|
||||
cacheInvalidate: true
|
||||
},
|
||||
options: [
|
||||
'name'
|
||||
],
|
||||
validation: {
|
||||
options: {
|
||||
name: {
|
||||
required: true
|
||||
}
|
||||
}
|
||||
},
|
||||
permissions: true,
|
||||
query(frame) {
|
||||
let themeName = frame.options.name;
|
||||
|
||||
if (themeName === 'casper') {
|
||||
throw new common.errors.ValidationError({
|
||||
message: common.i18n.t('errors.api.themes.destroyCasper')
|
||||
});
|
||||
}
|
||||
|
||||
if (themeName === settingsCache.get('active_theme')) {
|
||||
throw new common.errors.ValidationError({
|
||||
message: common.i18n.t('errors.api.themes.destroyActive')
|
||||
});
|
||||
}
|
||||
|
||||
const theme = themeService.list.get(themeName);
|
||||
|
||||
if (!theme) {
|
||||
throw new common.errors.NotFoundError({
|
||||
message: common.i18n.t('errors.api.themes.themeDoesNotExist')
|
||||
});
|
||||
}
|
||||
|
||||
return themeService.storage.delete(themeName)
|
||||
.then(() => {
|
||||
themeService.list.del(themeName);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
|
@ -85,5 +85,9 @@ module.exports = {
|
|||
|
||||
get configuration() {
|
||||
return require('./configuration');
|
||||
},
|
||||
|
||||
get themes() {
|
||||
return require('./themes');
|
||||
}
|
||||
};
|
||||
|
|
29
core/server/api/v2/utils/serializers/output/themes.js
Normal file
29
core/server/api/v2/utils/serializers/output/themes.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:themes');
|
||||
|
||||
module.exports = {
|
||||
browse(themes, apiConfig, frame) {
|
||||
debug('browse');
|
||||
|
||||
frame.response = themes;
|
||||
|
||||
debug(frame.response);
|
||||
},
|
||||
|
||||
upload() {
|
||||
debug('upload');
|
||||
this.browse(...arguments);
|
||||
},
|
||||
|
||||
activate() {
|
||||
debug('activate');
|
||||
this.browse(...arguments);
|
||||
},
|
||||
|
||||
download(fn, apiConfig, frame) {
|
||||
debug('download');
|
||||
|
||||
frame.response = fn;
|
||||
|
||||
debug(frame.response);
|
||||
}
|
||||
};
|
|
@ -108,28 +108,28 @@ module.exports = function apiRoutes() {
|
|||
router.get('/slugs/:type/:name', mw.authAdminApi, apiv2.http(apiv2.slugs.generate));
|
||||
|
||||
// ## Themes
|
||||
router.get('/themes/', mw.authAdminApi, api.http(api.themes.browse));
|
||||
router.get('/themes/', mw.authAdminApi, apiv2.http(apiv2.themes.browse));
|
||||
|
||||
router.get('/themes/:name/download',
|
||||
mw.authAdminApi,
|
||||
api.http(api.themes.download)
|
||||
apiv2.http(apiv2.themes.download)
|
||||
);
|
||||
|
||||
router.post('/themes/upload',
|
||||
mw.authAdminApi,
|
||||
upload.single('theme'),
|
||||
shared.middlewares.validation.upload({type: 'themes'}),
|
||||
api.http(api.themes.upload)
|
||||
apiv2.http(apiv2.themes.upload)
|
||||
);
|
||||
|
||||
router.put('/themes/:name/activate',
|
||||
mw.authAdminApi,
|
||||
api.http(api.themes.activate)
|
||||
apiv2.http(apiv2.themes.activate)
|
||||
);
|
||||
|
||||
router.del('/themes/:name',
|
||||
mw.authAdminApi,
|
||||
api.http(api.themes.destroy)
|
||||
apiv2.http(apiv2.themes.destroy)
|
||||
);
|
||||
|
||||
// ## Notifications
|
||||
|
|
285
core/test/acceptance/old/admin/themes_spec.js
Normal file
285
core/test/acceptance/old/admin/themes_spec.js
Normal file
|
@ -0,0 +1,285 @@
|
|||
const should = require('should');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const _ = require('lodash');
|
||||
const supertest = require('supertest');
|
||||
const testUtils = require('../../../utils');
|
||||
const localUtils = require('./utils');
|
||||
const config = require('../../../../server/config');
|
||||
const ghost = testUtils.startGhost;
|
||||
|
||||
describe('v2 Themes API', function () {
|
||||
let ghostServer;
|
||||
let ownerRequest;
|
||||
|
||||
const uploadTheme = (options) => {
|
||||
const themePath = options.themePath;
|
||||
const fieldName = options.fieldName || 'theme';
|
||||
const request = options.request || ownerRequest;
|
||||
|
||||
return request
|
||||
.post(localUtils.API.getApiQuery('themes/upload'))
|
||||
.set('Origin', config.get('url'))
|
||||
.attach(fieldName, themePath);
|
||||
};
|
||||
|
||||
before(function () {
|
||||
return ghost()
|
||||
.then((_ghostServer) => {
|
||||
ghostServer = _ghostServer;
|
||||
});
|
||||
});
|
||||
|
||||
before(function () {
|
||||
ownerRequest = supertest.agent(config.get('url'));
|
||||
return localUtils.doAuth(ownerRequest);
|
||||
});
|
||||
|
||||
it('browse', function () {
|
||||
return ownerRequest
|
||||
.get(localUtils.API.getApiQuery('themes/'))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
const jsonResponse = res.body;
|
||||
should.exist(jsonResponse.themes);
|
||||
localUtils.API.checkResponse(jsonResponse, 'themes');
|
||||
jsonResponse.themes.length.should.eql(5);
|
||||
|
||||
localUtils.API.checkResponse(jsonResponse.themes[0], 'theme');
|
||||
jsonResponse.themes[0].name.should.eql('broken-theme');
|
||||
jsonResponse.themes[0].package.should.be.an.Object().with.properties('name', 'version');
|
||||
jsonResponse.themes[0].active.should.be.false();
|
||||
|
||||
localUtils.API.checkResponse(jsonResponse.themes[1], 'theme', 'templates');
|
||||
jsonResponse.themes[1].name.should.eql('casper');
|
||||
jsonResponse.themes[1].package.should.be.an.Object().with.properties('name', 'version');
|
||||
jsonResponse.themes[1].active.should.be.true();
|
||||
|
||||
localUtils.API.checkResponse(jsonResponse.themes[2], 'theme');
|
||||
jsonResponse.themes[2].name.should.eql('casper-1.4');
|
||||
jsonResponse.themes[2].package.should.be.an.Object().with.properties('name', 'version');
|
||||
jsonResponse.themes[2].active.should.be.false();
|
||||
|
||||
localUtils.API.checkResponse(jsonResponse.themes[3], 'theme');
|
||||
jsonResponse.themes[3].name.should.eql('test-theme');
|
||||
jsonResponse.themes[3].package.should.be.an.Object().with.properties('name', 'version');
|
||||
jsonResponse.themes[3].active.should.be.false();
|
||||
|
||||
localUtils.API.checkResponse(jsonResponse.themes[4], 'theme');
|
||||
jsonResponse.themes[4].name.should.eql('test-theme-channels');
|
||||
jsonResponse.themes[4].package.should.be.false();
|
||||
jsonResponse.themes[4].active.should.be.false();
|
||||
});
|
||||
});
|
||||
|
||||
it('download', function () {
|
||||
return ownerRequest
|
||||
.get(localUtils.API.getApiQuery('themes/casper/download/'))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Content-Type', /application\/zip/)
|
||||
.expect('Content-Disposition', 'attachment; filename=casper.zip')
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('upload valid theme', function () {
|
||||
return uploadTheme({themePath: path.join(__dirname, '..', '..', '..', 'utils', 'fixtures', 'themes', 'valid.zip')})
|
||||
.then((res) => {
|
||||
const jsonResponse = res.body;
|
||||
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
|
||||
should.exist(jsonResponse.themes);
|
||||
localUtils.API.checkResponse(jsonResponse, 'themes');
|
||||
jsonResponse.themes.length.should.eql(1);
|
||||
localUtils.API.checkResponse(jsonResponse.themes[0], 'theme');
|
||||
jsonResponse.themes[0].name.should.eql('valid');
|
||||
jsonResponse.themes[0].active.should.be.false();
|
||||
|
||||
// upload same theme again to force override
|
||||
return uploadTheme({themePath: path.join(__dirname, '..', '..', '..', 'utils', 'fixtures', 'themes', 'valid.zip')});
|
||||
})
|
||||
.then((res) => {
|
||||
const jsonResponse = res.body;
|
||||
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
should.exist(jsonResponse.themes);
|
||||
localUtils.API.checkResponse(jsonResponse, 'themes');
|
||||
jsonResponse.themes.length.should.eql(1);
|
||||
localUtils.API.checkResponse(jsonResponse.themes[0], 'theme');
|
||||
jsonResponse.themes[0].name.should.eql('valid');
|
||||
jsonResponse.themes[0].active.should.be.false();
|
||||
|
||||
// ensure tmp theme folder contains two themes now
|
||||
const tmpFolderContents = fs.readdirSync(config.getContentPath('themes'));
|
||||
tmpFolderContents.forEach((theme, index) => {
|
||||
if (theme.match(/^\./)) {
|
||||
tmpFolderContents.splice(index, 1);
|
||||
}
|
||||
});
|
||||
tmpFolderContents.should.be.an.Array().with.lengthOf(10);
|
||||
|
||||
tmpFolderContents.should.eql([
|
||||
'broken-theme',
|
||||
'casper',
|
||||
'casper-1.4',
|
||||
'casper.zip',
|
||||
'invalid.zip',
|
||||
'test-theme',
|
||||
'test-theme-channels',
|
||||
'valid',
|
||||
'valid.zip',
|
||||
'warnings.zip'
|
||||
]);
|
||||
|
||||
// Check the Themes API returns the correct result
|
||||
return ownerRequest
|
||||
.get(localUtils.API.getApiQuery('themes/'))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect(200);
|
||||
})
|
||||
.then((res) => {
|
||||
const jsonResponse = res.body;
|
||||
|
||||
should.exist(jsonResponse.themes);
|
||||
localUtils.API.checkResponse(jsonResponse, 'themes');
|
||||
jsonResponse.themes.length.should.eql(6);
|
||||
|
||||
// Casper should be present and still active
|
||||
const casperTheme = _.find(jsonResponse.themes, {name: 'casper'});
|
||||
should.exist(casperTheme);
|
||||
localUtils.API.checkResponse(casperTheme, 'theme', 'templates');
|
||||
casperTheme.active.should.be.true();
|
||||
|
||||
// The added theme should be here
|
||||
const addedTheme = _.find(jsonResponse.themes, {name: 'valid'});
|
||||
should.exist(addedTheme);
|
||||
localUtils.API.checkResponse(addedTheme, 'theme');
|
||||
addedTheme.active.should.be.false();
|
||||
});
|
||||
});
|
||||
|
||||
it('delete', function () {
|
||||
return ownerRequest
|
||||
.del(localUtils.API.getApiQuery('themes/valid'))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect(204)
|
||||
.then((res) => {
|
||||
const jsonResponse = res.body;
|
||||
// Delete requests have empty bodies
|
||||
jsonResponse.should.eql({});
|
||||
|
||||
// ensure tmp theme folder contains one theme again now
|
||||
const tmpFolderContents = fs.readdirSync(config.getContentPath('themes'));
|
||||
tmpFolderContents.forEach((theme, index) => {
|
||||
if (theme.match(/^\./)) {
|
||||
tmpFolderContents.splice(index, 1);
|
||||
}
|
||||
});
|
||||
tmpFolderContents.should.be.an.Array().with.lengthOf(9);
|
||||
|
||||
tmpFolderContents.should.eql([
|
||||
'broken-theme',
|
||||
'casper',
|
||||
'casper-1.4',
|
||||
'casper.zip',
|
||||
'invalid.zip',
|
||||
'test-theme',
|
||||
'test-theme-channels',
|
||||
'valid.zip',
|
||||
'warnings.zip'
|
||||
]);
|
||||
|
||||
// Check the themes API returns the correct result after deletion
|
||||
return ownerRequest
|
||||
.get(localUtils.API.getApiQuery('themes/'))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect(200);
|
||||
})
|
||||
.then((res) => {
|
||||
const jsonResponse = res.body;
|
||||
|
||||
should.exist(jsonResponse.themes);
|
||||
localUtils.API.checkResponse(jsonResponse, 'themes');
|
||||
jsonResponse.themes.length.should.eql(5);
|
||||
|
||||
// Casper should be present and still active
|
||||
const casperTheme = _.find(jsonResponse.themes, {name: 'casper'});
|
||||
should.exist(casperTheme);
|
||||
localUtils.API.checkResponse(casperTheme, 'theme', 'templates');
|
||||
casperTheme.active.should.be.true();
|
||||
|
||||
// The deleted theme should not be here
|
||||
const deletedTheme = _.find(jsonResponse.themes, {name: 'valid'});
|
||||
should.not.exist(deletedTheme);
|
||||
});
|
||||
});
|
||||
|
||||
it('upload with warnings', function () {
|
||||
return uploadTheme({themePath: path.join(__dirname, '/../../../utils/fixtures/themes/warnings.zip')})
|
||||
.then((res) => {
|
||||
const jsonResponse = res.body;
|
||||
|
||||
should.exist(jsonResponse.themes);
|
||||
localUtils.API.checkResponse(jsonResponse, 'themes');
|
||||
jsonResponse.themes.length.should.eql(1);
|
||||
localUtils.API.checkResponse(jsonResponse.themes[0], 'theme', ['warnings']);
|
||||
jsonResponse.themes[0].name.should.eql('warnings');
|
||||
jsonResponse.themes[0].active.should.be.false();
|
||||
jsonResponse.themes[0].warnings.should.be.an.Array();
|
||||
|
||||
// Delete the theme to clean up after the test
|
||||
return ownerRequest
|
||||
.del(localUtils.API.getApiQuery('themes/warnings'))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect(204);
|
||||
});
|
||||
});
|
||||
|
||||
it('activate', function () {
|
||||
return ownerRequest
|
||||
.get(localUtils.API.getApiQuery('themes/'))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
const jsonResponse = res.body;
|
||||
|
||||
should.exist(jsonResponse.themes);
|
||||
localUtils.API.checkResponse(jsonResponse, 'themes');
|
||||
jsonResponse.themes.length.should.eql(5);
|
||||
|
||||
const casperTheme = _.find(jsonResponse.themes, {name: 'casper'});
|
||||
should.exist(casperTheme);
|
||||
localUtils.API.checkResponse(casperTheme, 'theme', 'templates');
|
||||
casperTheme.active.should.be.true();
|
||||
|
||||
const testTheme = _.find(jsonResponse.themes, {name: 'test-theme'});
|
||||
should.exist(testTheme);
|
||||
localUtils.API.checkResponse(testTheme, 'theme');
|
||||
testTheme.active.should.be.false();
|
||||
|
||||
// Finally activate the new theme
|
||||
return ownerRequest
|
||||
.put(localUtils.API.getApiQuery('themes/test-theme/activate'))
|
||||
.set('Origin', config.get('url'))
|
||||
.expect(200);
|
||||
})
|
||||
.then((res) => {
|
||||
const jsonResponse = res.body;
|
||||
|
||||
should.exist(res.headers['x-cache-invalidate']);
|
||||
should.exist(jsonResponse.themes);
|
||||
localUtils.API.checkResponse(jsonResponse, 'themes');
|
||||
jsonResponse.themes.length.should.eql(1);
|
||||
|
||||
const casperTheme = _.find(jsonResponse.themes, {name: 'casper'});
|
||||
should.not.exist(casperTheme);
|
||||
|
||||
const testTheme = _.find(jsonResponse.themes, {name: 'test-theme'});
|
||||
should.exist(testTheme);
|
||||
localUtils.API.checkResponse(testTheme, 'theme', ['warnings', 'templates']);
|
||||
testTheme.active.should.be.true();
|
||||
testTheme.warnings.should.be.an.Array();
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue