feat: Allow defining active plugins in config (#10767)

* Revert "Revert "feat: cross origin opener policy options (#10710)""

This reverts commit 46050ace1a.

* Revert "Revert "chore(i18n): fallback strings for new resources: nodebb.admin-settings-advanced""

This reverts commit 9f291c07d3.

* feat: closes #10719, don't trim children if category is marked section

* feat: fire hook to allow plugins to filter the pids returned in a user profile

/cc julianlam/nodebb-plugin-support-forum#14

* fix: use `user.hidePrivateData();` more consistently across user retrieval endpoints

* feat: Allow defining active plugins in config

resolves #10766

* fix: assign the db result to files properly

* test: add tests with plugins in config

* feat: better theme change handling

* feat: add visual indication that plugins can't be activated

* test: correct hooks

* test: fix test definitions

* test: remove instead of resetting nconf to avoid affecting other tests

* test: ... I forgot how nconf worked

* fix: remove negation

* docs: improve wording of error message

* feat: reduce code duplication

* style: remove a redundant space

* fix: remove unused imports

* fix: use nconf instead of requiring config.json

* fix: await...

* fix: second missed await

* fix: move back from getActiveIds to getActive

* fix: use paths again?

* fix: typo

* fix: move require into the function

* fix: forgot to change back to getActive

* test: getActive returns only id

* test: accedently commented out some stuff

* feat: added note to top of plugins page if \!canChangeState

Co-authored-by: Julian Lam <julian@nodebb.org>
Co-authored-by: Barış Soner Uşaklı <barisusakli@gmail.com>
isekai-main
Opliko 3 years ago committed by Julian Lam
parent a6af47da02
commit 23cb67a112

@ -20,6 +20,7 @@ const prestart = require('./src/prestart');
prestart.loadConfig(configFile); prestart.loadConfig(configFile);
const db = require('./src/database'); const db = require('./src/database');
const plugins = require('./src/plugins');
module.exports = function (grunt) { module.exports = function (grunt) {
const args = []; const args = [];
@ -40,35 +41,35 @@ module.exports = function (grunt) {
grunt.registerTask('init', async function () { grunt.registerTask('init', async function () {
const done = this.async(); const done = this.async();
let plugins = []; let pluginList = [];
if (!process.argv.includes('--core')) { if (!process.argv.includes('--core')) {
await db.init(); await db.init();
plugins = await db.getSortedSetRange('plugins:active', 0, -1); pluginList = await plugins.getActive();
addBaseThemes(plugins); addBaseThemes(pluginList);
if (!plugins.includes('nodebb-plugin-composer-default')) { if (!pluginList.includes('nodebb-plugin-composer-default')) {
plugins.push('nodebb-plugin-composer-default'); pluginList.push('nodebb-plugin-composer-default');
} }
if (!plugins.includes('nodebb-theme-persona')) { if (!pluginList.includes('nodebb-theme-persona')) {
plugins.push('nodebb-theme-persona'); pluginList.push('nodebb-theme-persona');
} }
} }
const styleUpdated_Client = plugins.map(p => `node_modules/${p}/*.less`) const styleUpdated_Client = pluginList.map(p => `node_modules/${p}/*.less`)
.concat(plugins.map(p => `node_modules/${p}/*.css`)) .concat(pluginList.map(p => `node_modules/${p}/*.css`))
.concat(plugins.map(p => `node_modules/${p}/+(public|static|less)/**/*.less`)) .concat(pluginList.map(p => `node_modules/${p}/+(public|static|less)/**/*.less`))
.concat(plugins.map(p => `node_modules/${p}/+(public|static)/**/*.css`)); .concat(pluginList.map(p => `node_modules/${p}/+(public|static)/**/*.css`));
const styleUpdated_Admin = plugins.map(p => `node_modules/${p}/*.less`) const styleUpdated_Admin = pluginList.map(p => `node_modules/${p}/*.less`)
.concat(plugins.map(p => `node_modules/${p}/*.css`)) .concat(pluginList.map(p => `node_modules/${p}/*.css`))
.concat(plugins.map(p => `node_modules/${p}/+(public|static|less)/**/*.less`)) .concat(pluginList.map(p => `node_modules/${p}/+(public|static|less)/**/*.less`))
.concat(plugins.map(p => `node_modules/${p}/+(public|static)/**/*.css`)); .concat(pluginList.map(p => `node_modules/${p}/+(public|static)/**/*.css`));
const clientUpdated = plugins.map(p => `node_modules/${p}/+(public|static)/**/*.js`); const clientUpdated = pluginList.map(p => `node_modules/${p}/+(public|static)/**/*.js`);
const serverUpdated = plugins.map(p => `node_modules/${p}/*.js`) const serverUpdated = pluginList.map(p => `node_modules/${p}/*.js`)
.concat(plugins.map(p => `node_modules/${p}/+(lib|src)/**/*.js`)); .concat(pluginList.map(p => `node_modules/${p}/+(lib|src)/**/*.js`));
const templatesUpdated = plugins.map(p => `node_modules/${p}/+(public|static|templates)/**/*.tpl`); const templatesUpdated = pluginList.map(p => `node_modules/${p}/+(public|static|templates)/**/*.tpl`);
const langUpdated = plugins.map(p => `node_modules/${p}/+(public|static|languages)/**/*.json`); const langUpdated = pluginList.map(p => `node_modules/${p}/+(public|static|languages)/**/*.json`);
grunt.config(['watch'], { grunt.config(['watch'], {
styleUpdated_Client: { styleUpdated_Client: {
@ -195,10 +196,10 @@ module.exports = function (grunt) {
}); });
}; };
function addBaseThemes(plugins) { function addBaseThemes(pluginList) {
let themeId = plugins.find(p => p.includes('nodebb-theme-')); let themeId = pluginList.find(p => p.includes('nodebb-theme-'));
if (!themeId) { if (!themeId) {
return plugins; return pluginList;
} }
let baseTheme; let baseTheme;
do { do {
@ -209,9 +210,9 @@ function addBaseThemes(plugins) {
} }
if (baseTheme) { if (baseTheme) {
plugins.push(baseTheme); pluginList.push(baseTheme);
themeId = baseTheme; themeId = baseTheme;
} }
} while (baseTheme); } while (baseTheme);
return plugins; return pluginList;
} }

@ -71,7 +71,7 @@ if (nconf.get('setup') || nconf.get('install')) {
}); });
} else if (nconf.get('activate')) { } else if (nconf.get('activate')) {
require('./src/cli/manage').activate(nconf.get('activate')); require('./src/cli/manage').activate(nconf.get('activate'));
} else if (nconf.get('plugins')) { } else if (nconf.get('plugins') && typeof nconf.get('plugins') !== 'object') {
require('./src/cli/manage').listPlugins(); require('./src/cli/manage').listPlugins();
} else if (nconf.get('build')) { } else if (nconf.get('build')) {
require('./src/cli/manage').build(nconf.get('build')); require('./src/cli/manage').build(nconf.get('build'));

@ -241,6 +241,8 @@
"socket-reconnect-failed": "Unable to reach the server at this time. Click here to try again, or try again later", "socket-reconnect-failed": "Unable to reach the server at this time. Click here to try again, or try again later",
"plugin-not-whitelisted": "Unable to install plugin &ndash; only plugins whitelisted by the NodeBB Package Manager can be installed via the ACP", "plugin-not-whitelisted": "Unable to install plugin &ndash; only plugins whitelisted by the NodeBB Package Manager can be installed via the ACP",
"plugins-set-in-configuration": "You are not allowed to change plugin state as they are defined at runtime (config.json, environmental variables or terminal arguments), please modify the configuration instead.",
"theme-not-set-in-configuration": "When defining active plugins in configuration, changing themes requires adding the new theme to the list of active plugins before updating it in the ACP",
"topic-event-unrecognized": "Topic event '%1' unrecognized", "topic-event-unrecognized": "Topic event '%1' unrecognized",

@ -4,6 +4,7 @@ const winston = require('winston');
const childProcess = require('child_process'); const childProcess = require('child_process');
const CliGraph = require('cli-graph'); const CliGraph = require('cli-graph');
const chalk = require('chalk'); const chalk = require('chalk');
const nconf = require('nconf');
const build = require('../meta/build'); const build = require('../meta/build');
const db = require('../database'); const db = require('../database');
@ -38,6 +39,10 @@ async function activate(plugin) {
winston.info('Plugin `%s` already active', plugin); winston.info('Plugin `%s` already active', plugin);
process.exit(0); process.exit(0);
} }
if (nconf.get('plugins:active')) {
winston.error('Cannot activate plugins while plugin state configuration is set, please change your active configuration (config.json, environmental variables or terminal arguments) instead');
process.exit(1);
}
const numPlugins = await db.sortedSetCard('plugins:active'); const numPlugins = await db.sortedSetCard('plugins:active');
winston.info('Activating plugin `%s`', plugin); winston.info('Activating plugin `%s`', plugin);
await db.sortedSetAdd('plugins:active', numPlugins, plugin); await db.sortedSetAdd('plugins:active', numPlugins, plugin);
@ -57,8 +62,7 @@ async function listPlugins() {
await db.init(); await db.init();
const installed = await plugins.showInstalled(); const installed = await plugins.showInstalled();
const installedList = installed.map(plugin => plugin.name); const installedList = installed.map(plugin => plugin.name);
const active = await db.getSortedSetRange('plugins:active', 0, -1); const active = await plugins.getActive();
// Merge the two sets, defer to plugins in `installed` if already present // Merge the two sets, defer to plugins in `installed` if already present
const combined = installed.concat(active.reduce((memo, cur) => { const combined = installed.concat(active.reduce((memo, cur) => {
if (!installedList.includes(cur)) { if (!installedList.includes(cur)) {
@ -108,13 +112,12 @@ async function info() {
const hash = childProcess.execSync('git rev-parse HEAD'); const hash = childProcess.execSync('git rev-parse HEAD');
console.log(` git hash: ${hash}`); console.log(` git hash: ${hash}`);
const config = require('../../config.json'); console.log(` database: ${nconf.get('database')}`);
console.log(` database: ${config.database}`);
await db.init(); await db.init();
const info = await db.info(db.client); const info = await db.info(db.client);
switch (config.database) { switch (nconf.get('database')) {
case 'redis': case 'redis':
console.log(` version: ${info.redis_version}`); console.log(` version: ${info.redis_version}`);
console.log(` disk sync: ${info.rdb_last_bgsave_status}`); console.log(` disk sync: ${info.rdb_last_bgsave_status}`);
@ -124,6 +127,10 @@ async function info() {
console.log(` version: ${info.version}`); console.log(` version: ${info.version}`);
console.log(` engine: ${info.storageEngine}`); console.log(` engine: ${info.storageEngine}`);
break; break;
case 'postgres':
console.log(` version: ${info.version}`);
console.log(` uptime: ${info.uptime}`);
break;
} }
const analyticsData = await analytics.getHourlyStatsForSet('analytics:pageviews', Date.now(), 24); const analyticsData = await analytics.getHourlyStatsForSet('analytics:pageviews', Date.now(), 24);

@ -4,6 +4,7 @@ const path = require('path');
const winston = require('winston'); const winston = require('winston');
const fs = require('fs'); const fs = require('fs');
const chalk = require('chalk'); const chalk = require('chalk');
const nconf = require('nconf');
const db = require('../database'); const db = require('../database');
const events = require('../events'); const events = require('../events');
@ -118,6 +119,10 @@ async function resetThemeTo(themeId) {
async function resetPlugin(pluginId) { async function resetPlugin(pluginId) {
try { try {
if (nconf.get('plugins:active')) {
winston.error('Cannot reset plugins while plugin state is set in the configuration (config.json, environmental variables or terminal arguments), please modify the configuration instead');
process.exit(1);
}
const isActive = await db.isSortedSetMember('plugins:active', pluginId); const isActive = await db.isSortedSetMember('plugins:active', pluginId);
if (isActive) { if (isActive) {
await db.sortedSetRemove('plugins:active', pluginId); await db.sortedSetRemove('plugins:active', pluginId);
@ -137,6 +142,10 @@ async function resetPlugin(pluginId) {
} }
async function resetPlugins() { async function resetPlugins() {
if (nconf.get('plugins:active')) {
winston.error('Cannot reset plugins while plugin state is set in the configuration (config.json, environmental variables or terminal arguments), please modify the configuration instead');
process.exit(1);
}
await db.delete('plugins:active'); await db.delete('plugins:active');
winston.info('[reset] All Plugins De-activated'); winston.info('[reset] All Plugins De-activated');
} }

@ -35,6 +35,7 @@ pluginsController.get = async function (req, res) {
installedCount: installedPlugins.length, installedCount: installedPlugins.length,
activeCount: activePlugins.length, activeCount: activePlugins.length,
inactiveCount: Math.max(0, installedPlugins.length - activePlugins.length), inactiveCount: Math.max(0, installedPlugins.length - activePlugins.length),
canChangeState: !nconf.get('plugins:active'),
upgradeCount: compatible.reduce((count, current) => { upgradeCount: compatible.reduce((count, current) => {
if (current.installed && current.outdated) { if (current.installed && current.outdated) {
count += 1; count += 1;

@ -199,9 +199,9 @@ exports.webpack = async function (options) {
const webpack = require('webpack'); const webpack = require('webpack');
const fs = require('fs'); const fs = require('fs');
const util = require('util'); const util = require('util');
const db = require('../database'); const plugins = require('../plugins/data');
const activePlugins = await db.getSortedSetRange('plugins:active', 0, -1); const activePlugins = await plugins.getActive();
if (!activePlugins.includes('nodebb-plugin-composer-default')) { if (!activePlugins.includes('nodebb-plugin-composer-default')) {
activePlugins.push('nodebb-plugin-composer-default'); activePlugins.push('nodebb-plugin-composer-default');
} }

@ -15,7 +15,6 @@ const Benchpress = require('benchpressjs');
const plugins = require('../plugins'); const plugins = require('../plugins');
const file = require('../file'); const file = require('../file');
const db = require('../database');
const { themeNamePattern, paths } = require('../constants'); const { themeNamePattern, paths } = require('../constants');
const viewsPath = nconf.get('views_dir'); const viewsPath = nconf.get('views_dir');
@ -119,7 +118,7 @@ async function compile() {
await _rimraf(viewsPath); await _rimraf(viewsPath);
await mkdirp(viewsPath); await mkdirp(viewsPath);
let files = await db.getSortedSetRange('plugins:active', 0, -1); let files = await plugins.getActive();
files = await getTemplateDirs(files); files = await getTemplateDirs(files);
files = await getTemplateFiles(files); files = await getTemplateFiles(files);

@ -101,6 +101,16 @@ Themes.set = async (data) => {
let config = await fs.promises.readFile(pathToThemeJson, 'utf8'); let config = await fs.promises.readFile(pathToThemeJson, 'utf8');
config = JSON.parse(config); config = JSON.parse(config);
const activePluginsConfig = nconf.get('plugins:active');
if (!activePluginsConfig) {
await db.sortedSetRemove('plugins:active', current);
const numPlugins = await db.sortedSetCard('plugins:active');
await db.sortedSetAdd('plugins:active', numPlugins, 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 new theme 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) // Re-set the themes path (for when NodeBB is reloaded)
Themes.setPath(config); Themes.setPath(config);

@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const winston = require('winston'); const winston = require('winston');
const _ = require('lodash'); const _ = require('lodash');
const nconf = require('nconf');
const db = require('../database'); const db = require('../database');
const file = require('../file'); const file = require('../file');
@ -13,11 +14,19 @@ const Data = module.exports;
const basePath = path.join(__dirname, '../../'); const basePath = path.join(__dirname, '../../');
// to get this functionality use `plugins.getActive()` from `src/plugins/install.js` instead
// this method duplicates that one, because requiring that file here would have side effects
async function getActiveIds() {
if (nconf.get('plugins:active')) {
return nconf.get('plugins:active');
}
return await db.getSortedSetRange('plugins:active', 0, -1);
}
Data.getPluginPaths = async function () { Data.getPluginPaths = async function () {
const plugins = await db.getSortedSetRange('plugins:active', 0, -1); const plugins = await getActiveIds();
const pluginPaths = plugins.filter(plugin => plugin && typeof plugin === 'string') const pluginPaths = plugins.filter(plugin => plugin && typeof plugin === 'string')
.map(plugin => path.join(paths.nodeModules, plugin)); .map(plugin => path.join(paths.nodeModules, plugin));
const exists = await Promise.all(pluginPaths.map(file.exists)); const exists = await Promise.all(pluginPaths.map(file.exists));
exists.forEach((exists, i) => { exists.forEach((exists, i) => {
if (!exists) { if (!exists) {
@ -96,7 +105,6 @@ Data.getStaticDirectories = async function (pluginData) {
route}. Path must adhere to: ${validMappedPath.toString()}`); route}. Path must adhere to: ${validMappedPath.toString()}`);
return; return;
} }
const dirPath = await resolveModulePath(pluginData.path, pluginData.staticDirs[route]); const dirPath = await resolveModulePath(pluginData.path, pluginData.staticDirs[route]);
try { try {
const stats = await fs.promises.stat(dirPath); const stats = await fs.promises.stat(dirPath);

@ -56,6 +56,10 @@ module.exports = function (Plugins) {
} }
Plugins.toggleActive = async function (id) { Plugins.toggleActive = async function (id) {
if (nconf.get('plugins:active')) {
winston.error('Cannot activate plugins while plugin state is set in the configuration (config.json, environmental variables or terminal arguments), please modify the configuration instead');
throw new Error('[[error:plugins-set-in-configuration]]');
}
const isActive = await Plugins.isActive(id); const isActive = await Plugins.isActive(id);
if (isActive) { if (isActive) {
await db.sortedSetRemove('plugins:active', id); await db.sortedSetRemove('plugins:active', id);
@ -144,10 +148,16 @@ module.exports = function (Plugins) {
}; };
Plugins.isActive = async function (id) { Plugins.isActive = async function (id) {
if (nconf.get('plugins:active')) {
return nconf.get('plugins:active').includes(id);
}
return await db.isSortedSetMember('plugins:active', id); return await db.isSortedSetMember('plugins:active', id);
}; };
Plugins.getActive = async function () { Plugins.getActive = async function () {
if (nconf.get('plugins:active')) {
return nconf.get('plugins:active');
}
return await db.getSortedSetRange('plugins:active', 0, -1); return await db.getSortedSetRange('plugins:active', 0, -1);
}; };

@ -1,5 +1,7 @@
'use strict'; 'use strict';
const nconf = require('nconf');
const plugins = require('../../plugins'); const plugins = require('../../plugins');
const events = require('../../events'); const events = require('../../events');
const db = require('../../database'); const db = require('../../database');
@ -35,6 +37,9 @@ Plugins.getActive = async function () {
}; };
Plugins.orderActivePlugins = async function (socket, data) { Plugins.orderActivePlugins = async function (socket, data) {
if (nconf.get('plugins:active')) {
throw new Error('[[error:plugins-set-in-configuration]]');
}
data = data.filter(plugin => plugin && plugin.name); data = data.filter(plugin => plugin && plugin.name);
await Promise.all(data.map(plugin => db.sortedSetAdd('plugins:active', plugin.order || 0, plugin.name))); await Promise.all(data.map(plugin => db.sortedSetAdd('plugins:active', plugin.order || 0, plugin.name)));
}; };

@ -8,10 +8,10 @@ const readline = require('readline');
const winston = require('winston'); const winston = require('winston');
const chalk = require('chalk'); const chalk = require('chalk');
const plugins = require('./plugins');
const db = require('./database'); const db = require('./database');
const file = require('./file'); const file = require('./file');
const { paths } = require('./constants'); const { paths } = require('./constants');
/* /*
* Need to write an upgrade script for NodeBB? Cool. * Need to write an upgrade script for NodeBB? Cool.
* *
@ -61,8 +61,8 @@ Upgrade.getAll = async function () {
Upgrade.appendPluginScripts = async function (files) { Upgrade.appendPluginScripts = async function (files) {
// Find all active plugins // Find all active plugins
const plugins = await db.getSortedSetRange('plugins:active', 0, -1); const activePlugins = await plugins.getActive();
plugins.forEach((plugin) => { activePlugins.forEach((plugin) => {
const configPath = path.join(paths.nodeModules, plugin, 'plugin.json'); const configPath = path.join(paths.nodeModules, plugin, 'plugin.json');
try { try {
const pluginConfig = require(configPath); const pluginConfig = require(configPath);

@ -1,3 +1,6 @@
{{{ if !canChangeState }}}
<div class="alert alert-warning">[[error:plugins-set-in-configuration]]</div>
{{{ end }}}
<ul class="nav nav-pills"> <ul class="nav nav-pills">
<li> <li>
<a href="#trending" data-toggle="tab"> <a href="#trending" data-toggle="tab">

@ -5,7 +5,7 @@
<!-- IF ../isTheme --> <!-- IF ../isTheme -->
<a href="{config.relative_path}/admin/appearance/themes" class="btn btn-info">[[admin/extend/plugins:plugin-item.themes]]</a> <a href="{config.relative_path}/admin/appearance/themes" class="btn btn-info">[[admin/extend/plugins:plugin-item.themes]]</a>
<!-- ELSE --> <!-- ELSE -->
<button data-action="toggleActive" class="btn <!-- IF ../active --> btn-warning<!-- ELSE --> btn-success<!-- ENDIF ../active -->"> <button data-action="toggleActive" class="btn <!-- IF ../active --> btn-warning<!-- ELSE --> btn-success<!-- ENDIF ../active --> <!-- IF !canChangeState -->disabled<!-- ENDIF -->">
<i class="fa fa-power-off"></i> <!-- IF ../active -->[[admin/extend/plugins:plugin-item.deactivate]]<!-- ELSE -->[[admin/extend/plugins:plugin-item.activate]]<!-- ENDIF ../active --></button> <i class="fa fa-power-off"></i> <!-- IF ../active -->[[admin/extend/plugins:plugin-item.deactivate]]<!-- ELSE -->[[admin/extend/plugins:plugin-item.activate]]<!-- ENDIF ../active --></button>
<!-- ENDIF ../isTheme --> <!-- ENDIF ../isTheme -->

@ -319,6 +319,85 @@ describe('Plugins', () => {
}); });
}); });
}); });
describe('plugin state set in configuration', () => {
const activePlugins = [
'nodebb-plugin-markdown',
'nodebb-plugin-mentions',
];
const inactivePlugin = 'nodebb-plugin-emoji';
beforeEach((done) => {
nconf.set('plugins:active', activePlugins);
done();
});
afterEach((done) => {
nconf.set('plugins:active', undefined);
done();
});
it('should return active plugin state from configuration', (done) => {
plugins.isActive(activePlugins[0], (err, isActive) => {
assert.ifError(err);
assert(isActive);
done();
});
});
it('should return inactive plugin state if not in configuration', (done) => {
plugins.isActive(inactivePlugin, (err, isActive) => {
assert.ifError(err);
assert(!isActive);
done();
});
});
it('should get a list of plugins from configuration', (done) => {
plugins.list((err, data) => {
assert.ifError(err);
const keys = ['id', 'name', 'url', 'description', 'latest', 'installed', 'active', 'latest'];
assert(Array.isArray(data));
keys.forEach((key) => {
assert(data[0].hasOwnProperty(key));
});
data.forEach((pluginData) => {
assert.equal(pluginData.active, activePlugins.includes(pluginData.id));
});
done();
});
});
it('should return a list of only active plugins from configuration', (done) => {
plugins.getActive((err, data) => {
assert.ifError(err);
assert(Array.isArray(data));
data.forEach((pluginData) => {
console.log(pluginData);
assert(activePlugins.includes(pluginData));
});
done();
});
});
it('should not deactivate a plugin if active plugins are set in configuration', (done) => {
assert.rejects(plugins.toggleActive(activePlugins[0]), Error).then(() => {
plugins.isActive(activePlugins[0], (err, isActive) => {
assert.ifError(err);
assert(isActive);
done();
});
});
});
it('should not activate a plugin if active plugins are set in configuration', (done) => {
assert.rejects(plugins.toggleActive(inactivePlugin), Error).then(() => {
plugins.isActive(inactivePlugin, (err, isActive) => {
assert.ifError(err);
assert(!isActive);
done();
});
});
});
});
}); });

Loading…
Cancel
Save