mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-26 01:26:12 +02:00
feat: Allow defining active plugins in config (#10767)
* Revert "Revert "feat: cross origin opener policy options (#10710)"" This reverts commit46050ace1a. * Revert "Revert "chore(i18n): fallback strings for new resources: nodebb.admin-settings-advanced"" This reverts commit9f291c07d3. * 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>
This commit is contained in:
51
Gruntfile.js
51
Gruntfile.js
@@ -20,6 +20,7 @@ const prestart = require('./src/prestart');
|
||||
prestart.loadConfig(configFile);
|
||||
|
||||
const db = require('./src/database');
|
||||
const plugins = require('./src/plugins');
|
||||
|
||||
module.exports = function (grunt) {
|
||||
const args = [];
|
||||
@@ -40,35 +41,35 @@ module.exports = function (grunt) {
|
||||
|
||||
grunt.registerTask('init', async function () {
|
||||
const done = this.async();
|
||||
let plugins = [];
|
||||
let pluginList = [];
|
||||
if (!process.argv.includes('--core')) {
|
||||
await db.init();
|
||||
plugins = await db.getSortedSetRange('plugins:active', 0, -1);
|
||||
addBaseThemes(plugins);
|
||||
if (!plugins.includes('nodebb-plugin-composer-default')) {
|
||||
plugins.push('nodebb-plugin-composer-default');
|
||||
pluginList = await plugins.getActive();
|
||||
addBaseThemes(pluginList);
|
||||
if (!pluginList.includes('nodebb-plugin-composer-default')) {
|
||||
pluginList.push('nodebb-plugin-composer-default');
|
||||
}
|
||||
if (!plugins.includes('nodebb-theme-persona')) {
|
||||
plugins.push('nodebb-theme-persona');
|
||||
if (!pluginList.includes('nodebb-theme-persona')) {
|
||||
pluginList.push('nodebb-theme-persona');
|
||||
}
|
||||
}
|
||||
|
||||
const styleUpdated_Client = plugins.map(p => `node_modules/${p}/*.less`)
|
||||
.concat(plugins.map(p => `node_modules/${p}/*.css`))
|
||||
.concat(plugins.map(p => `node_modules/${p}/+(public|static|less)/**/*.less`))
|
||||
.concat(plugins.map(p => `node_modules/${p}/+(public|static)/**/*.css`));
|
||||
const styleUpdated_Client = pluginList.map(p => `node_modules/${p}/*.less`)
|
||||
.concat(pluginList.map(p => `node_modules/${p}/*.css`))
|
||||
.concat(pluginList.map(p => `node_modules/${p}/+(public|static|less)/**/*.less`))
|
||||
.concat(pluginList.map(p => `node_modules/${p}/+(public|static)/**/*.css`));
|
||||
|
||||
const styleUpdated_Admin = plugins.map(p => `node_modules/${p}/*.less`)
|
||||
.concat(plugins.map(p => `node_modules/${p}/*.css`))
|
||||
.concat(plugins.map(p => `node_modules/${p}/+(public|static|less)/**/*.less`))
|
||||
.concat(plugins.map(p => `node_modules/${p}/+(public|static)/**/*.css`));
|
||||
const styleUpdated_Admin = pluginList.map(p => `node_modules/${p}/*.less`)
|
||||
.concat(pluginList.map(p => `node_modules/${p}/*.css`))
|
||||
.concat(pluginList.map(p => `node_modules/${p}/+(public|static|less)/**/*.less`))
|
||||
.concat(pluginList.map(p => `node_modules/${p}/+(public|static)/**/*.css`));
|
||||
|
||||
const clientUpdated = plugins.map(p => `node_modules/${p}/+(public|static)/**/*.js`);
|
||||
const serverUpdated = plugins.map(p => `node_modules/${p}/*.js`)
|
||||
.concat(plugins.map(p => `node_modules/${p}/+(lib|src)/**/*.js`));
|
||||
const clientUpdated = pluginList.map(p => `node_modules/${p}/+(public|static)/**/*.js`);
|
||||
const serverUpdated = pluginList.map(p => `node_modules/${p}/*.js`)
|
||||
.concat(pluginList.map(p => `node_modules/${p}/+(lib|src)/**/*.js`));
|
||||
|
||||
const templatesUpdated = plugins.map(p => `node_modules/${p}/+(public|static|templates)/**/*.tpl`);
|
||||
const langUpdated = plugins.map(p => `node_modules/${p}/+(public|static|languages)/**/*.json`);
|
||||
const templatesUpdated = pluginList.map(p => `node_modules/${p}/+(public|static|templates)/**/*.tpl`);
|
||||
const langUpdated = pluginList.map(p => `node_modules/${p}/+(public|static|languages)/**/*.json`);
|
||||
|
||||
grunt.config(['watch'], {
|
||||
styleUpdated_Client: {
|
||||
@@ -195,10 +196,10 @@ module.exports = function (grunt) {
|
||||
});
|
||||
};
|
||||
|
||||
function addBaseThemes(plugins) {
|
||||
let themeId = plugins.find(p => p.includes('nodebb-theme-'));
|
||||
function addBaseThemes(pluginList) {
|
||||
let themeId = pluginList.find(p => p.includes('nodebb-theme-'));
|
||||
if (!themeId) {
|
||||
return plugins;
|
||||
return pluginList;
|
||||
}
|
||||
let baseTheme;
|
||||
do {
|
||||
@@ -209,9 +210,9 @@ function addBaseThemes(plugins) {
|
||||
}
|
||||
|
||||
if (baseTheme) {
|
||||
plugins.push(baseTheme);
|
||||
pluginList.push(baseTheme);
|
||||
themeId = baseTheme;
|
||||
}
|
||||
} while (baseTheme);
|
||||
return plugins;
|
||||
return pluginList;
|
||||
}
|
||||
|
||||
2
app.js
2
app.js
@@ -71,7 +71,7 @@ if (nconf.get('setup') || nconf.get('install')) {
|
||||
});
|
||||
} else if (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();
|
||||
} else if (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",
|
||||
|
||||
"plugin-not-whitelisted": "Unable to install plugin – 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",
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ const winston = require('winston');
|
||||
const childProcess = require('child_process');
|
||||
const CliGraph = require('cli-graph');
|
||||
const chalk = require('chalk');
|
||||
const nconf = require('nconf');
|
||||
|
||||
const build = require('../meta/build');
|
||||
const db = require('../database');
|
||||
@@ -38,6 +39,10 @@ async function activate(plugin) {
|
||||
winston.info('Plugin `%s` already active', plugin);
|
||||
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');
|
||||
winston.info('Activating plugin `%s`', plugin);
|
||||
await db.sortedSetAdd('plugins:active', numPlugins, plugin);
|
||||
@@ -57,8 +62,7 @@ async function listPlugins() {
|
||||
await db.init();
|
||||
const installed = await plugins.showInstalled();
|
||||
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
|
||||
const combined = installed.concat(active.reduce((memo, cur) => {
|
||||
if (!installedList.includes(cur)) {
|
||||
@@ -108,13 +112,12 @@ async function info() {
|
||||
const hash = childProcess.execSync('git rev-parse HEAD');
|
||||
console.log(` git hash: ${hash}`);
|
||||
|
||||
const config = require('../../config.json');
|
||||
console.log(` database: ${config.database}`);
|
||||
console.log(` database: ${nconf.get('database')}`);
|
||||
|
||||
await db.init();
|
||||
const info = await db.info(db.client);
|
||||
|
||||
switch (config.database) {
|
||||
switch (nconf.get('database')) {
|
||||
case 'redis':
|
||||
console.log(` version: ${info.redis_version}`);
|
||||
console.log(` disk sync: ${info.rdb_last_bgsave_status}`);
|
||||
@@ -124,6 +127,10 @@ async function info() {
|
||||
console.log(` version: ${info.version}`);
|
||||
console.log(` engine: ${info.storageEngine}`);
|
||||
break;
|
||||
case 'postgres':
|
||||
console.log(` version: ${info.version}`);
|
||||
console.log(` uptime: ${info.uptime}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const analyticsData = await analytics.getHourlyStatsForSet('analytics:pageviews', Date.now(), 24);
|
||||
|
||||
@@ -4,6 +4,7 @@ const path = require('path');
|
||||
const winston = require('winston');
|
||||
const fs = require('fs');
|
||||
const chalk = require('chalk');
|
||||
const nconf = require('nconf');
|
||||
|
||||
const db = require('../database');
|
||||
const events = require('../events');
|
||||
@@ -118,6 +119,10 @@ async function resetThemeTo(themeId) {
|
||||
|
||||
async function resetPlugin(pluginId) {
|
||||
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);
|
||||
if (isActive) {
|
||||
await db.sortedSetRemove('plugins:active', pluginId);
|
||||
@@ -137,6 +142,10 @@ async function resetPlugin(pluginId) {
|
||||
}
|
||||
|
||||
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');
|
||||
winston.info('[reset] All Plugins De-activated');
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ pluginsController.get = async function (req, res) {
|
||||
installedCount: installedPlugins.length,
|
||||
activeCount: activePlugins.length,
|
||||
inactiveCount: Math.max(0, installedPlugins.length - activePlugins.length),
|
||||
canChangeState: !nconf.get('plugins:active'),
|
||||
upgradeCount: compatible.reduce((count, current) => {
|
||||
if (current.installed && current.outdated) {
|
||||
count += 1;
|
||||
|
||||
@@ -199,9 +199,9 @@ exports.webpack = async function (options) {
|
||||
const webpack = require('webpack');
|
||||
const fs = require('fs');
|
||||
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')) {
|
||||
activePlugins.push('nodebb-plugin-composer-default');
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ const Benchpress = require('benchpressjs');
|
||||
|
||||
const plugins = require('../plugins');
|
||||
const file = require('../file');
|
||||
const db = require('../database');
|
||||
const { themeNamePattern, paths } = require('../constants');
|
||||
|
||||
const viewsPath = nconf.get('views_dir');
|
||||
@@ -119,7 +118,7 @@ async function compile() {
|
||||
await _rimraf(viewsPath);
|
||||
await mkdirp(viewsPath);
|
||||
|
||||
let files = await db.getSortedSetRange('plugins:active', 0, -1);
|
||||
let files = await plugins.getActive();
|
||||
files = await getTemplateDirs(files);
|
||||
files = await getTemplateFiles(files);
|
||||
|
||||
|
||||
@@ -101,6 +101,16 @@ Themes.set = async (data) => {
|
||||
|
||||
let config = await fs.promises.readFile(pathToThemeJson, 'utf8');
|
||||
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)
|
||||
Themes.setPath(config);
|
||||
|
||||
@@ -4,6 +4,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const winston = require('winston');
|
||||
const _ = require('lodash');
|
||||
const nconf = require('nconf');
|
||||
|
||||
const db = require('../database');
|
||||
const file = require('../file');
|
||||
@@ -13,11 +14,19 @@ const Data = module.exports;
|
||||
|
||||
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 () {
|
||||
const plugins = await db.getSortedSetRange('plugins:active', 0, -1);
|
||||
const plugins = await getActiveIds();
|
||||
const pluginPaths = plugins.filter(plugin => plugin && typeof plugin === 'string')
|
||||
.map(plugin => path.join(paths.nodeModules, plugin));
|
||||
|
||||
const exists = await Promise.all(pluginPaths.map(file.exists));
|
||||
exists.forEach((exists, i) => {
|
||||
if (!exists) {
|
||||
@@ -96,7 +105,6 @@ Data.getStaticDirectories = async function (pluginData) {
|
||||
route}. Path must adhere to: ${validMappedPath.toString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dirPath = await resolveModulePath(pluginData.path, pluginData.staticDirs[route]);
|
||||
try {
|
||||
const stats = await fs.promises.stat(dirPath);
|
||||
|
||||
@@ -56,6 +56,10 @@ module.exports = function (Plugins) {
|
||||
}
|
||||
|
||||
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);
|
||||
if (isActive) {
|
||||
await db.sortedSetRemove('plugins:active', id);
|
||||
@@ -144,10 +148,16 @@ module.exports = function (Plugins) {
|
||||
};
|
||||
|
||||
Plugins.isActive = async function (id) {
|
||||
if (nconf.get('plugins:active')) {
|
||||
return nconf.get('plugins:active').includes(id);
|
||||
}
|
||||
return await db.isSortedSetMember('plugins:active', id);
|
||||
};
|
||||
|
||||
Plugins.getActive = async function () {
|
||||
if (nconf.get('plugins:active')) {
|
||||
return nconf.get('plugins:active');
|
||||
}
|
||||
return await db.getSortedSetRange('plugins:active', 0, -1);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const nconf = require('nconf');
|
||||
|
||||
const plugins = require('../../plugins');
|
||||
const events = require('../../events');
|
||||
const db = require('../../database');
|
||||
@@ -35,6 +37,9 @@ Plugins.getActive = async function () {
|
||||
};
|
||||
|
||||
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);
|
||||
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 chalk = require('chalk');
|
||||
|
||||
const plugins = require('./plugins');
|
||||
const db = require('./database');
|
||||
const file = require('./file');
|
||||
const { paths } = require('./constants');
|
||||
|
||||
/*
|
||||
* Need to write an upgrade script for NodeBB? Cool.
|
||||
*
|
||||
@@ -61,8 +61,8 @@ Upgrade.getAll = async function () {
|
||||
|
||||
Upgrade.appendPluginScripts = async function (files) {
|
||||
// Find all active plugins
|
||||
const plugins = await db.getSortedSetRange('plugins:active', 0, -1);
|
||||
plugins.forEach((plugin) => {
|
||||
const activePlugins = await plugins.getActive();
|
||||
activePlugins.forEach((plugin) => {
|
||||
const configPath = path.join(paths.nodeModules, plugin, 'plugin.json');
|
||||
try {
|
||||
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">
|
||||
<li>
|
||||
<a href="#trending" data-toggle="tab">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<!-- IF ../isTheme -->
|
||||
<a href="{config.relative_path}/admin/appearance/themes" class="btn btn-info">[[admin/extend/plugins:plugin-item.themes]]</a>
|
||||
<!-- 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>
|
||||
<!-- 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user