From 3e228072ba8077b64d45fec56f29e74d96d203d8 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Fri, 12 Feb 2021 09:19:17 +0000 Subject: [PATCH] Added ability to install themes directly from GitHub (#12635) refs https://github.com/TryGhost/Ghost/issues/12608 - adds `admin/canary/themes/install` endpoint to the Admin API - requires two query params. `source` must be set to "github". `ref` should refer to a GitHub repo in the format "{org}/{repo}" - downloads zip archive for the repo from github - runs downloaded zip through the same process as uploaded zips --- core/server/analytics-events.js | 12 ++- core/server/api/canary/themes.js | 84 ++++++++++++++++++- .../canary/utils/serializers/output/themes.js | 5 ++ core/server/translations/en.json | 3 +- core/server/web/api/canary/admin/routes.js | 2 + 5 files changed, 101 insertions(+), 5 deletions(-) diff --git a/core/server/analytics-events.js b/core/server/analytics-events.js index 1e02ebaf40..76ba3c5e03 100644 --- a/core/server/analytics-events.js +++ b/core/server/analytics-events.js @@ -19,7 +19,10 @@ module.exports.init = function () { }, { event: 'theme.uploaded', - name: 'Theme Uploaded' + name: 'Theme Uploaded', + // {keyOnSuppliedEventData: keyOnTrackedEventData} + // - used to extract specific properties from event data and give them meaningful names + data: {name: 'name'} }, { event: 'integration.added', @@ -28,8 +31,11 @@ module.exports.init = function () { ]; _.each(toTrack, function (track) { - events.on(track.event, function () { - analytics.track(_.extend(trackDefaults, {event: prefix + track.name})); + events.on(track.event, function (eventData = {}) { + // extract desired properties from eventData and rename keys if necessary + const data = _.mapValues(track.data || {}, v => eventData[v]); + + analytics.track(_.extend(trackDefaults, data, {event: prefix + track.name})); }); }); }; diff --git a/core/server/api/canary/themes.js b/core/server/api/canary/themes.js index 173cc43933..94fb9c4e80 100644 --- a/core/server/api/canary/themes.js +++ b/core/server/api/canary/themes.js @@ -1,6 +1,13 @@ +const fs = require('fs-extra'); +const os = require('os'); +const path = require('path'); +const security = require('@tryghost/security'); const {events} = require('../../lib/common'); const themeService = require('../../../frontend/services/themes'); const models = require('../../models'); +const request = require('../../lib/request'); +const errors = require('@tryghost/errors/lib/errors'); +const i18n = require('../../lib/common/i18n'); module.exports = { docName: 'themes', @@ -46,6 +53,81 @@ module.exports = { } }, + install: { + headers: {}, + options: [ + 'source', + 'ref' + ], + validation: { + source: { + required: true, + values: ['github'] + }, + ref: { + required: true + } + }, + permissions: { + method: 'add' + }, + async query(frame) { + if (frame.options.source === 'github') { + const [org, repo] = frame.options.ref.toLowerCase().split('/'); + + // omit /:ref so we fetch the default branch + const zipUrl = `https://api.github.com/repos/${org}/${repo}/zipball`; + const zipName = `${repo}.zip`; + + // store zip in a unique temporary folder to avoid conflicts + const downloadBase = path.join(os.tmpdir(), security.identifier.uid(10)); + const downloadPath = path.join(downloadBase, zipName); + + await fs.ensureDir(downloadBase); + + try { + // download zip file + const response = await request(zipUrl, { + followRedirect: true, + headers: { + accept: 'application/vnd.github.v3+json' + }, + encoding: null + }); + + await fs.writeFile(downloadPath, response.body); + + // install theme from zip + const zip = { + path: downloadPath, + name: zipName + }; + const {theme, themeOverridden} = await themeService.storage.setFromZip(zip); + + if (themeOverridden) { + this.headers.cacheInvalidate = true; + } + + events.emit('theme.uploaded', {name: theme.name}); + + return theme; + } catch (e) { + if (e.statusCode && e.statusCode === 404) { + return Promise.reject(new errors.BadRequestError({ + message: i18n.t('errors.api.themes.repoDoesNotExist'), + context: zipUrl + })); + } + + throw e; + } finally { + // clean up tmp dir with downloaded file + fs.remove(downloadBase); + } + } + } + }, + upload: { headers: {}, permissions: { @@ -66,7 +148,7 @@ module.exports = { // CASE: clear cache this.headers.cacheInvalidate = true; } - events.emit('theme.uploaded'); + events.emit('theme.uploaded', {name: theme.name}); return theme; }); } diff --git a/core/server/api/canary/utils/serializers/output/themes.js b/core/server/api/canary/utils/serializers/output/themes.js index ded38408c3..0cfa74e7e4 100644 --- a/core/server/api/canary/utils/serializers/output/themes.js +++ b/core/server/api/canary/utils/serializers/output/themes.js @@ -12,6 +12,11 @@ module.exports = { this.browse(...arguments); }, + install() { + debug('install'); + this.browse(...arguments); + }, + activate() { debug('activate'); this.browse(...arguments); diff --git a/core/server/translations/en.json b/core/server/translations/en.json index ce328b7903..33010dbdcd 100644 --- a/core/server/translations/en.json +++ b/core/server/translations/en.json @@ -431,7 +431,8 @@ "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.", - "destroyActive": "Deleting the active theme is not allowed." + "destroyActive": "Deleting the active theme is not allowed.", + "repoDoesNotExist": "Supplied GitHub theme does not exist or is inaccessible" }, "images": { "missingFile": "Please select an image.", diff --git a/core/server/web/api/canary/admin/routes.js b/core/server/web/api/canary/admin/routes.js index 34671219c1..c9fe6260a2 100644 --- a/core/server/web/api/canary/admin/routes.js +++ b/core/server/web/api/canary/admin/routes.js @@ -142,6 +142,8 @@ module.exports = function apiRoutes() { http(apiCanary.themes.upload) ); + router.post('/themes/install', mw.authAdminApi, http(apiCanary.themes.install)); + router.put('/themes/:name/activate', mw.authAdminApi, http(apiCanary.themes.activate)