NodeBB/NodeBB

View on GitHub
src/meta/themes.js

Summary

Maintainability
A
1 hr
Test Coverage
'use strict';

const path = require('path');
const nconf = require('nconf');
const winston = require('winston');
const _ = require('lodash');
const fs = require('fs');

const file = require('../file');
const db = require('../database');
const Meta = require('./index');
const events = require('../events');
const utils = require('../utils');
const { themeNamePattern } = require('../constants');

const Themes = module.exports;

Themes.get = async () => {
    const themePath = nconf.get('themes_path');
    if (typeof themePath !== 'string') {
        return [];
    }

    let themes = await getThemes(themePath);
    themes = _.flatten(themes).filter(Boolean);
    themes = await Promise.all(themes.map(async (theme) => {
        const config = path.join(themePath, theme, 'theme.json');
        const pack = path.join(themePath, theme, 'package.json');
        try {
            const [configFile, packageFile] = await Promise.all([
                fs.promises.readFile(config, 'utf8'),
                fs.promises.readFile(pack, 'utf8'),
            ]);
            const configObj = JSON.parse(configFile);
            const packageObj = JSON.parse(packageFile);

            configObj.id = packageObj.name;

            // Minor adjustments for API output
            configObj.type = 'local';
            if (configObj.screenshot) {
                configObj.screenshot_url = `${nconf.get('relative_path')}/css/previews/${encodeURIComponent(configObj.id)}`;
            } else {
                configObj.screenshot_url = `${nconf.get('relative_path')}/assets/images/themes/default.png`;
            }

            return configObj;
        } catch (err) {
            if (err.code === 'ENOENT') {
                return false;
            }

            winston.error(`[themes] Unable to parse theme.json ${theme}`);
            return false;
        }
    }));

    return themes.filter(Boolean);
};

async function getThemes(themePath) {
    let dirs = await fs.promises.readdir(themePath);
    dirs = dirs.filter(dir => themeNamePattern.test(dir) || dir.startsWith('@'));
    return await Promise.all(dirs.map(async (dir) => {
        try {
            const dirpath = path.join(themePath, dir);
            const stat = await fs.promises.stat(dirpath);
            if (!stat.isDirectory()) {
                return false;
            }

            if (!dir.startsWith('@')) {
                return dir;
            }

            const themes = await getThemes(path.join(themePath, dir));
            return themes.map(theme => path.join(dir, theme));
        } catch (err) {
            if (err.code === 'ENOENT') {
                return false;
            }

            throw err;
        }
    }));
}

Themes.set = async (data) => {
    switch (data.type) {
        case 'local': {
            const current = await Meta.configs.get('theme:id');
            const score = await db.sortedSetScore('plugins:active', current);
            await db.sortedSetRemove('plugins:active', current);
            await db.sortedSetAdd('plugins:active', score || 0, data.id);

            if (current !== data.id) {
                const pathToThemeJson = path.join(nconf.get('themes_path'), data.id, 'theme.json');
                if (!pathToThemeJson.startsWith(nconf.get('themes_path'))) {
                    throw new Error('[[error:invalid-theme-id]]');
                }

                let config = await fs.promises.readFile(pathToThemeJson, 'utf8');
                config = JSON.parse(config);
                const activePluginsConfig = nconf.get('plugins:active');
                if (!activePluginsConfig) {
                    const score = await db.sortedSetScore('plugins:active', current);
                    await db.sortedSetRemove('plugins:active', current);
                    await db.sortedSetAdd('plugins:active', score || 0, data.id);
                } else if (!activePluginsConfig.includes(data.id)) {
                    // This prevents changing theme when configuration doesn't include it, but allows it otherwise
                    winston.error(`When defining active plugins in configuration, changing themes requires adding the theme '${data.id}' to the list of active plugins before updating it in the ACP`);
                    throw new Error('[[error:theme-not-set-in-configuration]]');
                }

                // Re-set the themes path (for when NodeBB is reloaded)
                Themes.setPath(config);

                await Meta.configs.setMultiple({
                    'theme:type': data.type,
                    'theme:id': data.id,
                    'theme:staticDir': config.staticDir ? config.staticDir : '',
                    'theme:templates': config.templates ? config.templates : '',
                    'theme:src': '',
                    bootswatchSkin: '',
                });

                await events.log({
                    type: 'theme-set',
                    uid: parseInt(data.uid, 10) || 0,
                    ip: data.ip || '127.0.0.1',
                    text: data.id,
                });

                Meta.reloadRequired = true;
            }
            break;
        }
        case 'bootswatch':
            await Meta.configs.setMultiple({
                'theme:src': data.src,
                bootswatchSkin: data.id.toLowerCase(),
            });
            break;
    }
};

Themes.setupPaths = async () => {
    const data = await utils.promiseParallel({
        themesData: Themes.get(),
        currentThemeId: Meta.configs.get('theme:id'),
    });

    const themeId = data.currentThemeId || 'nodebb-theme-harmony';

    if (process.env.NODE_ENV === 'development') {
        winston.info(`[themes] Using theme ${themeId}`);
    }

    const themeObj = data.themesData.find(themeObj => themeObj.id === themeId);

    if (!themeObj) {
        throw new Error('theme-not-found');
    }

    Themes.setPath(themeObj);
};

Themes.setPath = function (themeObj) {
    // Theme's templates path
    let themePath;
    const fallback = path.join(nconf.get('themes_path'), themeObj.id, 'templates');

    if (themeObj.templates) {
        themePath = path.join(nconf.get('themes_path'), themeObj.id, themeObj.templates);
    } else if (file.existsSync(fallback)) {
        themePath = fallback;
    } else {
        winston.error('[themes] Unable to resolve this theme\'s templates. Expected to be at "templates/" or defined in the "templates" property of "theme.json"');
        throw new Error('theme-missing-templates');
    }

    nconf.set('theme_templates_path', themePath);
    nconf.set('theme_config', path.join(nconf.get('themes_path'), themeObj.id, 'theme.json'));
};