diff --git a/core/server/api/v2/index.js b/core/server/api/v2/index.js index a8130900da..d529f9a9c4 100644 --- a/core/server/api/v2/index.js +++ b/core/server/api/v2/index.js @@ -29,5 +29,9 @@ module.exports = { get posts() { return shared.pipeline(require('./posts'), localUtils); + }, + + get settings() { + return shared.pipeline(require('./settings'), localUtils); } }; diff --git a/core/server/api/v2/settings.js b/core/server/api/v2/settings.js new file mode 100644 index 0000000000..0b93530e62 --- /dev/null +++ b/core/server/api/v2/settings.js @@ -0,0 +1,177 @@ +const Promise = require('bluebird'); +const _ = require('lodash'); +const moment = require('moment-timezone'); +const fs = require('fs-extra'); +const path = require('path'); +const config = require('../../config'); +const models = require('../../models'); +const urlService = require('../../services/url'); +const common = require('../../lib/common'); +const settingsCache = require('../../services/settings/cache'); + +module.exports = { + docName: 'settings', + + browse: { + options: ['type'], + permissions: true, + query(frame) { + let settings = settingsCache.getAll(); + + // CASE: no context passed (functional call) + if (!frame.options.context) { + return Promise.resolve(settings.filter((setting) => { + return setting.type === 'blog'; + })); + } + + // CASE: omit core settings unless internal request + if (!frame.options.context.internal) { + settings = _.filter(settings, (setting) => { + return setting.type !== 'core' && setting.key !== 'permalinks'; + }); + } + + return settings; + } + }, + + read: { + options: ['key'], + validation: { + options: { + key: { + required: true + } + } + }, + permissions: { + before(frame) { + let setting = settingsCache.get(frame.options.key, {resolve: false}); + + if (setting.type === 'core' && !(frame.options.context && frame.options.context.internal)) { + return Promise.reject(new common.errors.NoPermissionError({ + message: common.i18n.t('errors.api.settings.accessCoreSettingFromExtReq') + })); + } + }, + identifier(frame) { + return frame.options.key; + } + }, + query(frame) { + let setting = settingsCache.get(frame.options.key, {resolve: false}); + + return { + [frame.options.key]: setting + }; + } + }, + + edit: { + headers: { + cacheInvalidate: true + }, + permissions: { + before(frame) { + const errors = []; + + frame.data.settings.map((setting) => { + if (setting.type === 'core' && !(frame.options.context && frame.options.context.internal)) { + errors.push(new common.errors.NoPermissionError({ + message: common.i18n.t('errors.api.settings.accessCoreSettingFromExtReq') + })); + } + }); + + if (errors.length) { + return Promise.reject(errors[0]); + } + } + }, + query(frame) { + let type = frame.data.settings.find((setting) => { + return setting.key === 'type'; + }); + + if (_.isObject(type)) { + type = type.value; + } + + frame.data.settings = _.reject(frame.data.settings, (setting) => { + return setting.key === 'type'; + }); + + return models.Settings.edit(frame.data.settings, frame.options); + } + }, + + upload: { + headers: { + cacheInvalidate: true + }, + permissions: { + method: 'edit' + }, + query(frame) { + const backupRoutesPath = path.join(config.getContentPath('settings'), `routes-${moment().format('YYYY-MM-DD-HH-mm-ss')}.yaml`); + + return fs.copy(`${config.getContentPath('settings')}/routes.yaml`, backupRoutesPath) + .then(() => { + return fs.copy(frame.file.path, `${config.getContentPath('settings')}/routes.yaml`); + }) + .then(() => { + urlService.resetGenerators({releaseResourcesOnly: true}); + }) + .then(() => { + const siteApp = require('../../web/site/app'); + + try { + return siteApp.reload(); + } catch (err) { + // bring back backup, otherwise your Ghost blog is broken + return fs.copy(backupRoutesPath, `${config.getContentPath('settings')}/routes.yaml`) + .then(() => { + return siteApp.reload(); + }) + .then(() => { + throw err; + }); + } + }); + } + }, + + download: { + headers: { + disposition: { + type: 'yaml', + value: 'Attachment; filename="routes.yaml"' + } + }, + response: { + format: 'plain' + }, + permissions: { + method: 'browse' + }, + query() { + const routesPath = path.join(config.getContentPath('settings'), 'routes.yaml'); + + return fs.readFile(routesPath, 'utf-8') + .catch((err) => { + if (err.code === 'ENOENT') { + return Promise.resolve([]); + } + + if (common.errors.utils.isIgnitionError(err)) { + throw err; + } + + throw new common.errors.NotFoundError({ + err: err + }); + }); + } + } +}; diff --git a/core/server/api/v2/utils/permissions.js b/core/server/api/v2/utils/permissions.js index 2d6d4ba620..71d1bf5721 100644 --- a/core/server/api/v2/utils/permissions.js +++ b/core/server/api/v2/utils/permissions.js @@ -11,8 +11,8 @@ const nonePublicAuth = (apiConfig, frame) => { let permissionIdentifier = frame.options.id; - if (apiConfig.permissionIdentifier) { - permissionIdentifier = apiConfig.permissionIdentifier(frame); + if (apiConfig.identifier) { + permissionIdentifier = apiConfig.identifier(frame); } const unsafeAttrObject = apiConfig.unsafeAttrs && _.has(frame, `data.[${apiConfig.docName}][0]`) ? _.pick(frame.data[apiConfig.docName][0], apiConfig.unsafeAttrs) : {}; diff --git a/core/server/api/v2/utils/serializers/input/index.js b/core/server/api/v2/utils/serializers/input/index.js index 4d602d3de0..5fbd00a3bb 100644 --- a/core/server/api/v2/utils/serializers/input/index.js +++ b/core/server/api/v2/utils/serializers/input/index.js @@ -5,5 +5,9 @@ module.exports = { get posts() { return require('./posts'); + }, + + get settings() { + return require('./settings'); } }; diff --git a/core/server/api/v2/utils/serializers/input/settings.js b/core/server/api/v2/utils/serializers/input/settings.js new file mode 100644 index 0000000000..d7505f7fca --- /dev/null +++ b/core/server/api/v2/utils/serializers/input/settings.js @@ -0,0 +1,17 @@ +const _ = require('lodash'); + +module.exports = { + edit(apiConfig, frame) { + // CASE: allow shorthand syntax where a single key and value are passed to edit instead of object and options + if (_.isString(frame.data)) { + frame.data = {settings: [{key: frame.data, value: frame.options}]}; + } + + // prepare data + frame.data.settings.forEach((setting) => { + if (!_.isString(setting.value)) { + setting.value = JSON.stringify(setting.value); + } + }); + } +}; diff --git a/core/server/api/v2/utils/serializers/output/index.js b/core/server/api/v2/utils/serializers/output/index.js index 4be5d9474a..f6c0fbc131 100644 --- a/core/server/api/v2/utils/serializers/output/index.js +++ b/core/server/api/v2/utils/serializers/output/index.js @@ -17,5 +17,9 @@ module.exports = { get posts() { return require('./posts'); + }, + + get settings() { + return require('./settings'); } }; diff --git a/core/server/api/v2/utils/serializers/output/settings.js b/core/server/api/v2/utils/serializers/output/settings.js new file mode 100644 index 0000000000..230c9fe478 --- /dev/null +++ b/core/server/api/v2/utils/serializers/output/settings.js @@ -0,0 +1,51 @@ +const _ = require('lodash'); +const _private = {}; + +/** + * ### Settings Filter + * Filters an object based on a given filter object + * @private + * @param {Object} settings + * @param {String} filter + * @returns {*} + */ +_private.settingsFilter = (settings, filter) => { + return _.fromPairs(_.toPairs(settings).filter((setting) => { + if (filter) { + return _.some(filter.split(','), (f) => { + return setting[1].type === f; + }); + } + return true; + })); +}; + +module.exports = { + browse(models, apiConfig, frame) { + let filteredSettings = _.values(_private.settingsFilter(models, frame.options.type)); + + frame.response = { + settings: filteredSettings, + meta: {} + }; + + if (frame.options.type) { + frame.response.meta.filters = { + type: frame.options.type + }; + } + }, + + read() { + this.browse(...arguments); + }, + + edit(models, apiConfig, frame) { + const settingsKeyedJSON = _.keyBy(_.invokeMap(models, 'toJSON'), 'key'); + this.browse(settingsKeyedJSON, apiConfig, frame); + }, + + download(bytes, apiConfig, frame) { + frame.response = bytes; + } +}; diff --git a/core/server/api/v2/utils/validators/input/index.js b/core/server/api/v2/utils/validators/input/index.js index f125461101..a15db2fc8a 100644 --- a/core/server/api/v2/utils/validators/input/index.js +++ b/core/server/api/v2/utils/validators/input/index.js @@ -1,5 +1,9 @@ module.exports = { get posts() { return require('./posts'); + }, + + get settings() { + return require('./settings'); } }; diff --git a/core/server/api/v2/utils/validators/input/settings.js b/core/server/api/v2/utils/validators/input/settings.js new file mode 100644 index 0000000000..f9c9f1fcf2 --- /dev/null +++ b/core/server/api/v2/utils/validators/input/settings.js @@ -0,0 +1,54 @@ +const Promise = require('bluebird'); +const _ = require('lodash'); +const common = require('../../../../../lib/common'); +const settingsCache = require('../../../../../services/settings/cache'); + +module.exports = { + read(apiConfig, frame) { + let setting = settingsCache.get(frame.options.key, {resolve: false}); + + if (!setting) { + return Promise.reject(new common.errors.NotFoundError({ + message: common.i18n.t('errors.api.settings.problemFindingSetting', {key: frame.options.key}) + })); + } + + // @NOTE: was removed (https://github.com/TryGhost/Ghost/commit/8bb7088ba026efd4a1c9cf7d6f1a5e9b4fa82575) + if (setting.key === 'permalinks') { + return Promise.reject(new common.errors.NotFoundError({ + message: common.i18n.t('errors.errors.resourceNotFound') + })); + } + }, + + edit(apiConfig, frame) { + const errors = []; + + _.each(frame.data.settings, (setting) => { + const settingFromCache = settingsCache.get(setting.key, {resolve: false}); + + if (!settingFromCache) { + errors.push(new common.errors.NotFoundError({ + message: common.i18n.t('errors.api.settings.problemFindingSetting', {key: setting.key}) + })); + } else if (settingFromCache.key === 'active_theme') { + // @NOTE: active theme has to be changed via theme endpoints + errors.push( + new common.errors.BadRequestError({ + message: common.i18n.t('errors.api.settings.activeThemeSetViaAPI.error'), + help: common.i18n.t('errors.api.settings.activeThemeSetViaAPI.help') + }) + ); + } else if (settingFromCache.key === 'permalinks') { + // @NOTE: was removed (https://github.com/TryGhost/Ghost/commit/8bb7088ba026efd4a1c9cf7d6f1a5e9b4fa82575) + errors.push(new common.errors.NotFoundError({ + message: common.i18n.t('errors.api.settings.problemFindingSetting', {key: setting.key}) + })); + } + }); + + if (errors.length) { + return Promise.reject(errors[0]); + } + } +}; diff --git a/core/server/web/api/v2/admin/routes.js b/core/server/web/api/v2/admin/routes.js index 71f4f981fe..54551f9e99 100644 --- a/core/server/web/api/v2/admin/routes.js +++ b/core/server/web/api/v2/admin/routes.js @@ -40,17 +40,17 @@ module.exports = function apiRoutes() { ], api.http(api.schedules.publishPost)); // ## Settings - router.get('/settings/routes/yaml', mw.authAdminAPI, api.http(api.settings.download)); + router.get('/settings/routes/yaml', mw.authAdminAPI, apiv2.http(apiv2.settings.download)); router.post('/settings/routes/yaml', mw.authAdminAPI, upload.single('routes'), shared.middlewares.validation.upload({type: 'routes'}), - api.http(api.settings.upload) + apiv2.http(apiv2.settings.upload) ); - router.get('/settings', mw.authAdminAPI, api.http(api.settings.browse)); - router.get('/settings/:key', mw.authAdminAPI, api.http(api.settings.read)); - router.put('/settings', mw.authAdminAPI, api.http(api.settings.edit)); + router.get('/settings', mw.authAdminAPI, apiv2.http(apiv2.settings.browse)); + router.get('/settings/:key', mw.authAdminAPI, apiv2.http(apiv2.settings.read)); + router.put('/settings', mw.authAdminAPI, apiv2.http(apiv2.settings.edit)); // ## Users router.get('/users', mw.authAdminAPI, api.http(api.users.browse)); diff --git a/core/test/functional/api/v2/admin/settings_spec.js b/core/test/functional/api/v2/admin/settings_spec.js new file mode 100644 index 0000000000..a2796c4d90 --- /dev/null +++ b/core/test/functional/api/v2/admin/settings_spec.js @@ -0,0 +1,261 @@ +const should = require('should'); +const _ = require('lodash'); +const supertest = require('supertest'); +const os = require('os'); +const fs = require('fs-extra'); +const config = require('../../../../../../core/server/config'); +const testUtils = require('../../../../utils'); +const localUtils = require('./utils'); +const ghost = testUtils.startGhost; +let request; + +describe('Settings API V2', function () { + let ghostServer; + + before(function () { + return ghost() + .then(function (_ghostServer) { + ghostServer = _ghostServer; + request = supertest.agent(config.get('url')); + }) + .then(function () { + return localUtils.doAuth(request); + }); + }); + + after(function () { + return ghostServer.stop(); + }); + + it('browse', function (done) { + request.get(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + var jsonResponse = res.body; + should.exist(jsonResponse); + + testUtils.API.checkResponse(jsonResponse, 'settings'); + + JSON.parse(_.find(jsonResponse.settings, {key: 'unsplash'}).value).isActive.should.eql(true); + JSON.parse(_.find(jsonResponse.settings, {key: 'amp'}).value).should.eql(true); + should.not.exist(_.find(jsonResponse.settings, {key: 'permalinks'})); + + testUtils.API.isISO8601(jsonResponse.settings[0].created_at).should.be.true(); + jsonResponse.settings[0].created_at.should.be.an.instanceof(String); + + should.not.exist(_.find(jsonResponse.settings, function (setting) { + return setting.type === 'core'; + })); + + done(); + }); + }); + + it('read', function (done) { + request.get(localUtils.API.getApiQuery('settings/title/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + var jsonResponse = res.body; + + should.exist(jsonResponse); + should.exist(jsonResponse.settings); + + testUtils.API.checkResponseValue(jsonResponse.settings[0], ['id', 'key', 'value', 'type', 'created_at', 'created_by', 'updated_at', 'updated_by']); + jsonResponse.settings[0].key.should.eql('title'); + testUtils.API.isISO8601(jsonResponse.settings[0].created_at).should.be.true(); + done(); + }); + }); + + it('read core setting', function () { + return request + .get(localUtils.API.getApiQuery('settings/db_hash/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(403); + }); + + it('can\'t read permalinks', function (done) { + request.get(localUtils.API.getApiQuery('settings/permalinks/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404) + .end(function (err, res) { + if (err) { + return done(err); + } + + done(); + }); + }); + + it('can\'t read non existent setting', function (done) { + request.get(localUtils.API.getApiQuery('settings/testsetting/')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404) + .end(function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + var jsonResponse = res.body; + should.exist(jsonResponse); + should.exist(jsonResponse.errors); + testUtils.API.checkResponseValue(jsonResponse.errors[0], ['message', 'errorType']); + done(); + }); + }); + + it('can edit settings', function (done) { + request.get(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .end(function (err, res) { + if (err) { + return done(err); + } + + var jsonResponse = res.body, + changedValue = [], + settingToChange = { + settings: [ + {key: 'title', value: changedValue} + ] + }; + + should.exist(jsonResponse); + should.exist(jsonResponse.settings); + + request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(settingToChange) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + var putBody = res.body; + res.headers['x-cache-invalidate'].should.eql('/*'); + should.exist(putBody); + putBody.settings[0].value.should.eql(JSON.stringify(changedValue)); + testUtils.API.checkResponse(putBody, 'settings'); + done(); + }); + }); + }); + + it('can\'t edit permalinks', function (done) { + const settingToChange = { + settings: [{key: 'permalinks', value: '/:primary_author/:slug/'}] + }; + + request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(settingToChange) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404) + .end(function (err, res) { + if (err) { + return done(err); + } + + done(); + }); + }); + + it('can\'t edit non existent setting', function (done) { + request.get(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .end(function (err, res) { + if (err) { + return done(err); + } + + var jsonResponse = res.body, + newValue = 'new value'; + should.exist(jsonResponse); + should.exist(jsonResponse.settings); + jsonResponse.settings = [{key: 'testvalue', value: newValue}]; + + request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(jsonResponse) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404) + .end(function (err, res) { + if (err) { + return done(err); + } + + jsonResponse = res.body; + should.not.exist(res.headers['x-cache-invalidate']); + should.exist(jsonResponse.errors); + testUtils.API.checkResponseValue(jsonResponse.errors[0], ['message', 'errorType']); + done(); + }); + }); + }); + + it('can download routes.yaml', ()=> { + return request.get(localUtils.API.getApiQuery('settings/routes/yaml/')) + .set('Origin', config.get('url')) + .set('Accept', 'application/yaml') + .expect(200) + .then((res)=> { + res.headers['content-disposition'].should.eql('Attachment; filename="routes.yaml"'); + res.headers['content-type'].should.eql('application/yaml; charset=utf-8'); + res.headers['content-length'].should.eql('138'); + }); + }); + + it('can upload routes.yaml', ()=> { + const newRoutesYamlPath = `${os.tmpdir()}/routes.yaml`; + + return fs.writeFile(newRoutesYamlPath, 'routes:\ncollections:\ntaxonomies:\n') + .then(()=> { + return request + .post(localUtils.API.getApiQuery('settings/routes/yaml/')) + .set('Origin', config.get('url')) + .attach('routes', newRoutesYamlPath) + .expect('Content-Type', /application\/json/) + .expect(200); + }) + .then((res)=> { + res.headers['x-cache-invalidate'].should.eql('/*'); + }) + .finally(()=> { + return ghostServer.stop(); + }); + }); +});