From fe90cf2be249096fa7ccca5e539073b1a4af1fed Mon Sep 17 00:00:00 2001
From: Hannah Wolfe <github.erisds@gmail.com>
Date: Tue, 21 Feb 2017 23:26:19 +0000
Subject: [PATCH] Theme loading part 1 (#7989)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

no issue

* ✨ Add new server start & stop events
* 🔥 Get rid of unused availableApps concept
- when we need an API endpoint for a list of apps, we'll build one 😝
* ✨ Move theme loading into a module
- move loading from API method to a module method and use as needed
- wire up read one vs read all as per LTS
- read one (the active theme) on boot, and read the rest after
- fudge validation - this isn't all that helpful
* Settings API tests need to preload themes
- this used to automatically happen as part of loading settings
- now we need to trigger this to happen specifically for this test
---
 core/server/api/settings.js                   | 13 +-----
 core/server/api/themes.js                     |  5 ++-
 core/server/data/validation/index.js          | 28 ++++---------
 core/server/ghost-server.js                   |  3 ++
 core/server/index.js                          | 11 ++---
 core/server/themes/index.js                   |  6 +++
 core/server/themes/loader.js                  | 39 +++++++++++++++++
 .../{utils/read-themes.js => themes/read.js}  | 32 ++++++++++----
 core/server/utils/index.js                    |  2 +-
 .../test/functional/routes/api/themes_spec.js |  2 +-
 .../test/integration/api/api_settings_spec.js | 28 +++++++------
 core/test/unit/server_utils_spec.js           | 33 ---------------
 core/test/unit/themes_spec.js                 | 42 +++++++++++++++++++
 core/test/utils/index.js                      |  4 +-
 14 files changed, 150 insertions(+), 98 deletions(-)
 create mode 100644 core/server/themes/loader.js
 rename core/server/{utils/read-themes.js => themes/read.js} (54%)
 create mode 100644 core/test/unit/themes_spec.js

diff --git a/core/server/api/settings.js b/core/server/api/settings.js
index 28532f39ad..22636d207a 100644
--- a/core/server/api/settings.js
+++ b/core/server/api/settings.js
@@ -136,7 +136,6 @@ readSettingsResult = function (settingsModels) {
             return memo;
         }, {}),
         themes = config.get('paths').availableThemes,
-        apps = config.get('paths').availableApps,
         res;
 
     // @TODO: remove availableThemes from settings cache and create an endpoint to fetch themes
@@ -150,16 +149,6 @@ readSettingsResult = function (settingsModels) {
         };
     }
 
-    if (settings.activeApps && apps) {
-        res = filterPaths(apps, JSON.parse(settings.activeApps.value));
-
-        settings.availableApps = {
-            key: 'availableApps',
-            value: res,
-            type: 'app'
-        };
-    }
-
     return settings;
 };
 
@@ -366,7 +355,7 @@ settings = {
         }
 
         object.settings = _.reject(object.settings, function (setting) {
-            return setting.key === 'type' || setting.key === 'availableThemes' || setting.key === 'availableApps';
+            return setting.key === 'type' || setting.key === 'availableThemes';
         });
 
         return canEditAllSettings(object.settings, options).then(function () {
diff --git a/core/server/api/themes.js b/core/server/api/themes.js
index 2bafb7aa55..88a3e28ee5 100644
--- a/core/server/api/themes.js
+++ b/core/server/api/themes.js
@@ -13,6 +13,7 @@ var Promise = require('bluebird'),
     apiUtils = require('./utils'),
     utils = require('./../utils'),
     i18n = require('../i18n'),
+    themeUtils = require('../themes'),
     themes;
 
 /**
@@ -88,7 +89,7 @@ themes = {
                 // 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 themes.loadThemes();
+                return themeUtils.load();
             })
             .then(function () {
                 // the settings endpoint is used to fetch the availableThemes
@@ -163,7 +164,7 @@ themes = {
                 return storageAdapter.delete(name, config.getContentPath('themes'));
             })
             .then(function () {
-                return themes.loadThemes();
+                return themeUtils.load();
             })
             .then(function () {
                 return settings.updateSettingsCache();
diff --git a/core/server/data/validation/index.js b/core/server/data/validation/index.js
index 2ef0fc3110..78a3e57bf5 100644
--- a/core/server/data/validation/index.js
+++ b/core/server/data/validation/index.js
@@ -6,15 +6,12 @@ var schema    = require('../schema').tables,
     Promise   = require('bluebird'),
     errors    = require('../../errors'),
     config    = require('../../config'),
-    readThemes  = require('../../utils/read-themes'),
     i18n        = require('../../i18n'),
 
     validateSchema,
     validateSettings,
     validateActiveTheme,
-    validate,
-
-    availableThemes;
+    validate;
 
 function assertString(input) {
     assert(typeof input === 'string', 'Validator js validates strings only');
@@ -131,24 +128,17 @@ validateSettings = function validateSettings(defaultSettings, model) {
 };
 
 validateActiveTheme = function validateActiveTheme(themeName) {
-    // If Ghost is running and its availableThemes collection exists
-    // give it priority.
-    if (config.get('paths').availableThemes && Object.keys(config.get('paths').availableThemes).length > 0) {
-        availableThemes = Promise.resolve(config.get('paths').availableThemes);
+    // @TODO come up with something way better here - we should probably attempt to read the theme from the
+    // File system at this point and validate the theme using gscan rather than just checking if it's in a cache object
+    if (!config.get('paths').availableThemes || Object.keys(config.get('paths').availableThemes).length === 0) {
+        // We haven't yet loaded all themes, this is probably being called early?
+        return Promise.resolve();
     }
 
-    if (!availableThemes) {
-        // A Promise that will resolve to an object with a property for each installed theme.
-        // This is necessary because certain configuration data is only available while Ghost
-        // is running and at times the validations are used when it's not (e.g. tests)
-        availableThemes = readThemes(config.getContentPath('themes'));
+    // Else, if we have a list, check if the theme is in it
+    if (!config.get('paths').availableThemes.hasOwnProperty(themeName)) {
+        return Promise.reject(new errors.ValidationError({message: i18n.t('notices.data.validation.index.themeCannotBeActivated', {themeName: themeName}), context: 'activeTheme'}));
     }
-
-    return availableThemes.then(function then(themes) {
-        if (!themes.hasOwnProperty(themeName)) {
-            return Promise.reject(new errors.ValidationError({message: i18n.t('notices.data.validation.index.themeCannotBeActivated', {themeName: themeName}), context: 'activeTheme'}));
-        }
-    });
 };
 
 // Validate default settings using the validator module.
diff --git a/core/server/ghost-server.js b/core/server/ghost-server.js
index 6e55e95c9f..65e665b010 100644
--- a/core/server/ghost-server.js
+++ b/core/server/ghost-server.js
@@ -7,6 +7,7 @@ var debug = require('debug')('ghost:server'),
     path = require('path'),
     _ = require('lodash'),
     errors = require('./errors'),
+    events = require('./events'),
     config = require('./config'),
     utils = require('./utils'),
     i18n   = require('./i18n'),
@@ -96,6 +97,7 @@ GhostServer.prototype.start = function (externalApp) {
         self.httpServer.on('connection', self.connection.bind(self));
         self.httpServer.on('listening', function () {
             debug('...Started');
+            events.emit('server:start');
             self.logStartMessages();
             resolve(self);
         });
@@ -116,6 +118,7 @@ GhostServer.prototype.stop = function () {
             resolve(self);
         } else {
             self.httpServer.close(function () {
+                events.emit('server:stop');
                 self.httpServer = null;
                 self.logShutdownMessages();
                 resolve(self);
diff --git a/core/server/index.js b/core/server/index.js
index aa17f91a93..f866f25c41 100644
--- a/core/server/index.js
+++ b/core/server/index.js
@@ -29,7 +29,7 @@ var debug = require('debug')('ghost:boot:init'),
     slack = require('./data/slack'),
     GhostServer = require('./ghost-server'),
     scheduling = require('./scheduling'),
-    readDirectory = require('./utils/read-directory'),
+    themes = require('./themes'),
     utils = require('./utils');
 
 // ## Initialise Ghost
@@ -44,13 +44,7 @@ function init() {
     models.init();
     debug('models done');
 
-    return readDirectory(config.getContentPath('apps')).then(function loadThemes(result) {
-        config.set('paths:availableApps', result);
-        return api.themes.loadThemes();
-    }).then(function () {
-        debug('Themes & apps done');
-        return dbHealth.check();
-    }).then(function () {
+    return dbHealth.check().then(function () {
         debug('DB health check done');
         // Populate any missing default settings
         return models.Settings.populateDefaults();
@@ -65,6 +59,7 @@ function init() {
     }).then(function () {
         debug('Permissions done');
         return Promise.join(
+            themes.init(),
             // Initialize apps
             apps.init(),
             // Initialize xmrpc ping
diff --git a/core/server/themes/index.js b/core/server/themes/index.js
index e69de29bb2..43a9e87e10 100644
--- a/core/server/themes/index.js
+++ b/core/server/themes/index.js
@@ -0,0 +1,6 @@
+var themeLoader = require('./loader');
+
+module.exports = {
+    init: themeLoader.init,
+    load: themeLoader.load
+};
diff --git a/core/server/themes/loader.js b/core/server/themes/loader.js
new file mode 100644
index 0000000000..38ff7f67e3
--- /dev/null
+++ b/core/server/themes/loader.js
@@ -0,0 +1,39 @@
+var debug = require('debug')('ghost:themes:loader'),
+    config = require('../config'),
+    events = require('../events'),
+    read = require('./read'),
+    settingsApi = require('../api/settings'),
+    updateConfigAndCache,
+    loadThemes,
+    initThemes;
+
+updateConfigAndCache = function updateConfigAndCache(themes) {
+    debug('loading themes', themes);
+    config.set('paths:availableThemes', themes);
+    settingsApi.updateSettingsCache();
+};
+
+loadThemes = function loadThemes() {
+    return read
+        .all(config.getContentPath('themes'))
+        .then(updateConfigAndCache);
+};
+
+initThemes = function initThemes() {
+    debug('init themes', settingsApi.cache.get('activeTheme'));
+
+    // Register a listener for server-start to load all themes
+    events.on('server:start', function readAllThemesOnServerStart() {
+        loadThemes();
+    });
+
+    // Just read the active theme for now
+    return read
+        .one(config.getContentPath('themes'), settingsApi.cache.get('activeTheme'))
+        .then(updateConfigAndCache);
+};
+
+module.exports = {
+    init: initThemes,
+    load: loadThemes
+};
diff --git a/core/server/utils/read-themes.js b/core/server/themes/read.js
similarity index 54%
rename from core/server/utils/read-themes.js
rename to core/server/themes/read.js
index 295d16ba88..881be08f14 100644
--- a/core/server/utils/read-themes.js
+++ b/core/server/themes/read.js
@@ -2,18 +2,31 @@
  * Dependencies
  */
 
-var readDirectory = require('./read-directory'),
+var readDirectory = require('../utils').readDirectory,
     Promise = require('bluebird'),
+    _ = require('lodash'),
     join = require('path').join,
     fs = require('fs'),
 
-    statFile = Promise.promisify(fs.stat);
+    statFile = Promise.promisify(fs.stat),
+    readOneTheme,
+    readAllThemes;
 
-/**
- * Read themes
- */
+readOneTheme = function readOneTheme(dir, name) {
+    var toRead = join(dir, name),
+        themes = {};
 
-function readThemes(dir) {
+    return readDirectory(toRead)
+        .then(function (tree) {
+            if (!_.isEmpty(tree)) {
+                themes[name] = tree;
+            }
+
+            return themes;
+        });
+};
+
+readAllThemes = function readAllThemes(dir) {
     var originalTree;
 
     return readDirectory(dir)
@@ -37,10 +50,11 @@ function readThemes(dir) {
 
             return themes;
         });
-}
+};
 
 /**
- * Expose `read-themes`
+ * Expose public API
  */
 
-module.exports = readThemes;
+module.exports.all = readAllThemes;
+module.exports.one = readOneTheme;
diff --git a/core/server/utils/index.js b/core/server/utils/index.js
index 9bcf9dd3d9..0d83df01c9 100644
--- a/core/server/utils/index.js
+++ b/core/server/utils/index.js
@@ -104,9 +104,9 @@ utils = {
     },
 
     readCSV: require('./read-csv'),
+    readDirectory: require('./read-directory'),
     removeOpenRedirectFromUrl: require('./remove-open-redirect-from-url'),
     zipFolder: require('./zip-folder'),
-    readThemes: require('./read-themes'),
     generateAssetHash: require('./asset-hash'),
     url: require('./url'),
     tokens: require('./tokens'),
diff --git a/core/test/functional/routes/api/themes_spec.js b/core/test/functional/routes/api/themes_spec.js
index 5c501fc7a5..8061e82daf 100644
--- a/core/test/functional/routes/api/themes_spec.js
+++ b/core/test/functional/routes/api/themes_spec.js
@@ -108,7 +108,7 @@ describe('Themes API', function () {
                 });
         });
 
-        it('get all available themes', function (done) {
+        it('get all available themes + new theme', function (done) {
             request.get(testUtils.API.getApiQuery('settings/'))
                 .set('Authorization', 'Bearer ' + scope.ownerAccessToken)
                 .end(function (err, res) {
diff --git a/core/test/integration/api/api_settings_spec.js b/core/test/integration/api/api_settings_spec.js
index 327bb1e423..0ddd2d7a15 100644
--- a/core/test/integration/api/api_settings_spec.js
+++ b/core/test/integration/api/api_settings_spec.js
@@ -164,18 +164,6 @@ describe('Settings API', function () {
         });
     });
 
-    it('does not allow an active theme which is not installed', function () {
-        return callApiWithContext(defaultContext, 'edit', 'activeTheme', {
-            settings: [{key: 'activeTheme', value: 'rasper'}]
-        }).then(function () {
-            throw new Error('Allowed to set an active theme which is not installed');
-        }).catch(function (err) {
-            should.exist(err);
-
-            err.errorType.should.eql('ValidationError');
-        });
-    });
-
     it('set activeTimezone: unknown timezone', function () {
         return callApiWithContext(defaultContext, 'edit', {settings: [{key: 'activeTimezone', value: 'MFG'}]}, {})
             .then(function () {
@@ -190,4 +178,20 @@ describe('Settings API', function () {
     it('set activeTimezone: known timezone', function () {
         return callApiWithContext(defaultContext, 'edit', {settings: [{key: 'activeTimezone', value: 'Etc/UTC'}]}, {});
     });
+
+    describe('Themes (to be removed from settings)', function () {
+        beforeEach(testUtils.setup('themes'));
+
+        it('does not allow an active theme which is not installed', function () {
+            return callApiWithContext(defaultContext, 'edit', 'activeTheme', {
+                settings: [{key: 'activeTheme', value: 'rasper'}]
+            }).then(function () {
+                throw new Error('Allowed to set an active theme which is not installed');
+            }).catch(function (err) {
+                should.exist(err);
+
+                err.errorType.should.eql('ValidationError');
+            });
+        });
+    });
 });
diff --git a/core/test/unit/server_utils_spec.js b/core/test/unit/server_utils_spec.js
index 11fae514a6..0a28bc1fff 100644
--- a/core/test/unit/server_utils_spec.js
+++ b/core/test/unit/server_utils_spec.js
@@ -7,7 +7,6 @@ var should          = require('should'),
     configUtils     = require('../utils/configUtils'),
     parsePackageJson = require('../../server/utils/parse-package-json'),
     readDirectory   = require('../../server/utils/read-directory'),
-    readThemes      = require('../../server/utils/read-themes'),
     gravatar        = require('../../server/utils/gravatar'),
     utils           = require('../../server/utils');
 
@@ -335,38 +334,6 @@ describe('Server Utilities', function () {
         });
     });
 
-    describe('read-themes', function () {
-        it('should read directory and include only folders', function (done) {
-            var themesPath = tmp.dirSync({unsafeCleanup: true});
-
-            // create trash
-            fs.writeFileSync(join(themesPath.name, 'casper.zip'));
-            fs.writeFileSync(join(themesPath.name, '.DS_Store'));
-
-            // create actual theme
-            fs.mkdirSync(join(themesPath.name, 'casper'));
-            fs.mkdirSync(join(themesPath.name, 'casper', 'partials'));
-            fs.writeFileSync(join(themesPath.name, 'casper', 'index.hbs'));
-            fs.writeFileSync(join(themesPath.name, 'casper', 'partials', 'navigation.hbs'));
-
-            readThemes(themesPath.name)
-                .then(function (tree) {
-                    tree.should.eql({
-                        casper: {
-                            partials: {
-                                'navigation.hbs': join(themesPath.name, 'casper', 'partials', 'navigation.hbs')
-                            },
-                            'index.hbs': join(themesPath.name, 'casper', 'index.hbs')
-                        }
-                    });
-
-                    done();
-                })
-                .catch(done)
-                .finally(themesPath.removeCallback);
-        });
-    });
-
     describe('gravatar-lookup', function () {
         beforeEach(function () {
             configUtils.set('privacy:useGravatar', true);
diff --git a/core/test/unit/themes_spec.js b/core/test/unit/themes_spec.js
new file mode 100644
index 0000000000..4444ebf61b
--- /dev/null
+++ b/core/test/unit/themes_spec.js
@@ -0,0 +1,42 @@
+var should          = require('should'),
+    fs              = require('fs'),
+    tmp             = require('tmp'),
+    join            = require('path').join,
+    readThemes      = require('../../server/themes/read');
+
+// To stop jshint complaining
+should.equal(true, true);
+
+describe('Themes', function () {
+    describe('Read', function () {
+        it('should read directory and include only folders', function (done) {
+            var themesPath = tmp.dirSync({unsafeCleanup: true});
+
+            // create trash
+            fs.writeFileSync(join(themesPath.name, 'casper.zip'));
+            fs.writeFileSync(join(themesPath.name, '.DS_Store'));
+
+            // create actual theme
+            fs.mkdirSync(join(themesPath.name, 'casper'));
+            fs.mkdirSync(join(themesPath.name, 'casper', 'partials'));
+            fs.writeFileSync(join(themesPath.name, 'casper', 'index.hbs'));
+            fs.writeFileSync(join(themesPath.name, 'casper', 'partials', 'navigation.hbs'));
+
+            readThemes.all(themesPath.name)
+                .then(function (tree) {
+                    tree.should.eql({
+                        casper: {
+                            partials: {
+                                'navigation.hbs': join(themesPath.name, 'casper', 'partials', 'navigation.hbs')
+                            },
+                            'index.hbs': join(themesPath.name, 'casper', 'index.hbs')
+                        }
+                    });
+
+                    done();
+                })
+                .catch(done)
+                .finally(themesPath.removeCallback);
+        });
+    });
+});
diff --git a/core/test/utils/index.js b/core/test/utils/index.js
index c94581aca4..f4f904d1c0 100644
--- a/core/test/utils/index.js
+++ b/core/test/utils/index.js
@@ -15,6 +15,7 @@ var Promise       = require('bluebird'),
     SettingsAPI   = require('../../server/api/settings'),
     permissions   = require('../../server/permissions'),
     sequence      = require('../../server/utils/sequence'),
+    themes        = require('../../server/themes'),
     DataGenerator = require('./fixtures/data-generator'),
     filterData    = require('./fixtures/filter-param'),
     API           = require('./api'),
@@ -449,7 +450,8 @@ toDoList = {
     },
     clients: function insertClients() { return fixtures.insertClients(); },
     filter: function createFilterParamFixtures() { return filterData(DataGenerator); },
-    invites: function insertInvites() { return fixtures.insertInvites(); }
+    invites: function insertInvites() { return fixtures.insertInvites(); },
+    themes: function loadThemes() { return themes.load(); }
 };
 
 /**