From eafbaaeba53b8ef7513d1bd978a3c25765732db0 Mon Sep 17 00:00:00 2001 From: kirrg001 Date: Wed, 30 Jan 2019 14:14:27 +0100 Subject: [PATCH] Added v2 theme controller refs #10060 --- core/server/api/shared/headers.js | 1 + core/server/api/v2/index.js | 4 + core/server/api/v2/themes.js | 217 +++++++++++++ .../api/v2/utils/serializers/output/index.js | 4 + .../api/v2/utils/serializers/output/themes.js | 29 ++ core/server/web/api/v2/admin/routes.js | 10 +- core/test/acceptance/old/admin/themes_spec.js | 285 ++++++++++++++++++ 7 files changed, 545 insertions(+), 5 deletions(-) create mode 100644 core/server/api/v2/themes.js create mode 100644 core/server/api/v2/utils/serializers/output/themes.js create mode 100644 core/test/acceptance/old/admin/themes_spec.js diff --git a/core/server/api/shared/headers.js b/core/server/api/shared/headers.js index 5fa131e907..35d7618794 100644 --- a/core/server/api/shared/headers.js +++ b/core/server/api/shared/headers.js @@ -1,4 +1,5 @@ const debug = require('ghost-ignition').debug('api:shared:headers'); +const Promise = require('bluebird'); const INVALIDATE_ALL = '/*'; const cacheInvalidate = (result, options = {}) => { diff --git a/core/server/api/v2/index.js b/core/server/api/v2/index.js index 932dd9ce2c..ca78354df3 100644 --- a/core/server/api/v2/index.js +++ b/core/server/api/v2/index.js @@ -105,5 +105,9 @@ module.exports = { get publicSettings() { return shared.pipeline(require('./settings-public'), localUtils); + }, + + get themes() { + return shared.pipeline(require('./themes'), localUtils); } }; diff --git a/core/server/api/v2/themes.js b/core/server/api/v2/themes.js new file mode 100644 index 0000000000..5386236141 --- /dev/null +++ b/core/server/api/v2/themes.js @@ -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); + }); + } + } +}; diff --git a/core/server/api/v2/utils/serializers/output/index.js b/core/server/api/v2/utils/serializers/output/index.js index 89d584b28e..56606591c5 100644 --- a/core/server/api/v2/utils/serializers/output/index.js +++ b/core/server/api/v2/utils/serializers/output/index.js @@ -85,5 +85,9 @@ module.exports = { get configuration() { return require('./configuration'); + }, + + get themes() { + return require('./themes'); } }; diff --git a/core/server/api/v2/utils/serializers/output/themes.js b/core/server/api/v2/utils/serializers/output/themes.js new file mode 100644 index 0000000000..082648020d --- /dev/null +++ b/core/server/api/v2/utils/serializers/output/themes.js @@ -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); + } +}; diff --git a/core/server/web/api/v2/admin/routes.js b/core/server/web/api/v2/admin/routes.js index eb5f872558..c3b47035b9 100644 --- a/core/server/web/api/v2/admin/routes.js +++ b/core/server/web/api/v2/admin/routes.js @@ -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 diff --git a/core/test/acceptance/old/admin/themes_spec.js b/core/test/acceptance/old/admin/themes_spec.js new file mode 100644 index 0000000000..c051bc9a5e --- /dev/null +++ b/core/test/acceptance/old/admin/themes_spec.js @@ -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(); + }); + }); +});