From d1ff3d6298710041f49397d4e2dcccbd929f8b63 Mon Sep 17 00:00:00 2001 From: gasoved Date: Mon, 4 Oct 2021 22:59:09 +0300 Subject: [PATCH] feat: cli user management commands (#9848) * feat: cli user management commands * fix: consistent nomenclature --- src/cli/colors.js | 16 +-- src/cli/index.js | 4 + src/cli/user.js | 310 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 src/cli/user.js diff --git a/src/cli/colors.js b/src/cli/colors.js index 35f30376ae..4ba0012a40 100644 --- a/src/cli/colors.js +++ b/src/cli/colors.js @@ -8,17 +8,13 @@ const { Command, Help } = require('commander'); const colors = [ // depth = 0, top-level command - { - command: 'yellow', - option: 'cyan', - arg: 'magenta', - }, + { command: 'yellow', option: 'cyan', arg: 'magenta' }, // depth = 1, second-level commands - { - command: 'green', - option: 'blue', - arg: 'red', - }, + { command: 'green', option: 'blue', arg: 'red' }, + // depth = 2, third-level commands + { command: 'yellow', option: 'cyan', arg: 'magenta' }, + // depth = 3 fourth-level commands + { command: 'green', option: 'blue', arg: 'red' }, ]; function humanReadableArgName(arg) { diff --git a/src/cli/index.js b/src/cli/index.js index e69910cf1f..b7ef494f36 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -253,6 +253,10 @@ resetCommand }); }); +// user +program + .addCommand(require('./user')()); + // upgrades program .command('upgrade [scripts...]') diff --git a/src/cli/user.js b/src/cli/user.js new file mode 100644 index 0000000000..e02622e263 --- /dev/null +++ b/src/cli/user.js @@ -0,0 +1,310 @@ +'use strict'; + +const { Command, Option } = require('commander'); + +module.exports = () => { + const userCmd = new Command('user') + .description('Manage users') + .arguments('[command]'); + + userCmd.configureHelp(require('./colors')); + const userCommands = UserCommands(); + + userCmd + .command('info') + .description('Display user info by uid/username/userslug.') + .option('-i, --uid ', 'Retrieve user by uid') + .option('-u, --username ', 'Retrieve user by username') + .option('-s, --userslug ', 'Retrieve user by userslug') + .action((...args) => execute(userCommands.info, args)); + userCmd + .command('create') + .description('Create a new user.') + .arguments('') + .option('-p, --password ', 'Set a new password. (Auto-generates if omitted)') + .option('-e, --email ', 'Associate with an email.') + .action((...args) => execute(userCommands.create, args)); + userCmd + .command('reset') + .description('Reset a user\'s password or send a password reset email.') + .arguments('') + .option('-p, --password ', 'Set a new password. (Auto-generates if passed empty)', false) + .option('-s, --send-reset-email', 'Send a password reset email.', false) + .action((...args) => execute(userCommands.reset, args)); + userCmd + .command('delete') + .description('Delete user(s) and/or their content') + .arguments('') + .addOption( + new Option('-c, --content [operation]', 'Delete user content ([purge]), leave content ([account]), or delete content only ([content])') + .choices(['purge', 'account', 'content']).default('purge') + ) + .action((...args) => execute(userCommands.deleteUser, args)); + + const make = userCmd.command('make') + .description('Make user(s) admin, global mod, moderator or a regular user.') + .arguments('[command]'); + + make.command('admin') + .description('Make user(s) an admin') + .arguments('') + .action((...args) => execute(userCommands.makeAdmin, args)); + make.command('global-mod') + .description('Make user(s) a global moderator') + .arguments('') + .action((...args) => execute(userCommands.makeGlobalMod, args)); + make.command('mod') + .description('Make uid(s) of user(s) moderator of given category IDs (cids)') + .arguments('') + .requiredOption('-c, --cid ', 'ID(s) of categories to make the user a moderator of') + .action((...args) => execute(userCommands.makeMod, args)); + make.command('regular') + .description('Make user(s) a non-privileged user') + .arguments('') + .action((...args) => execute(userCommands.makeRegular, args)); + + return userCmd; +}; + +let db; +let user; +let groups; +let privileges; +let privHelpers; +let utils; +let winston; + +async function init() { + db = require('../database'); + await db.init(); + + user = require('../user'); + groups = require('../groups'); + privileges = require('../privileges'); + privHelpers = require('../privileges/helpers'); + utils = require('../utils'); + winston = require('winston'); +} + +async function execute(cmd, args) { + await init(); + try { + await cmd(...args); + } catch (err) { + const userError = err.name === 'UserError'; + winston.error(`[userCmd/${cmd.name}] ${userError ? `${err.message}` : 'Command failed.'}`, userError ? '' : err); + process.exit(1); + } + + process.exit(); +} + +function UserCmdHelpers() { + async function getAdminUidOrFail() { + const adminUid = (await db.getSortedSetMembers('group:administrators:members')).reverse()[0]; + if (!adminUid) { + const err = new Error('An admin account does not exists to execute the operation.'); + err.name = 'UserError'; + throw err; + } + return adminUid; + } + + async function setupApp() { + const nconf = require('nconf'); + const Benchpress = require('benchpressjs'); + + const meta = require('../meta'); + await meta.configs.init(); + + const webserver = require('../webserver'); + const viewsDir = nconf.get('views_dir'); + + webserver.app.engine('tpl', (filepath, data, next) => { + filepath = filepath.replace(/\.tpl$/, '.js'); + + Benchpress.__express(filepath, data, next); + }); + webserver.app.set('view engine', 'tpl'); + webserver.app.set('views', viewsDir); + + const emailer = require('../emailer'); + emailer.registerApp(webserver.app); + } + + const argParsers = { + intParse: (value, varName) => { + const parsedValue = parseInt(value, 10); + if (isNaN(parsedValue)) { + const err = new Error(`"${varName}" expected to be a number.`); + err.name = 'UserError'; + throw err; + } + return parsedValue; + }, + intArrayParse: (values, varName) => values.map(value => argParsers.intParse(value, varName)), + }; + + return { + argParsers, + getAdminUidOrFail, + setupApp, + }; +} + +function UserCommands() { + const { argParsers, getAdminUidOrFail, setupApp } = UserCmdHelpers(); + + async function info({ uid, username, userslug }) { + if (!uid && !username && !userslug) { + return winston.error('[userCmd/info] At least one option has to be passed (--uid, --username or --userslug).'); + } + + if (uid) { + uid = argParsers.intParse(uid, 'uid'); + } else if (username) { + uid = await user.getUidByUsername(username); + } else { + uid = await user.getUidByUserslug(userslug); + } + + const userData = await user.getUserData(uid); + winston.info('[userCmd/info] User info retrieved:'); + console.log(userData); + } + + async function create(username, { password, email }) { + let pwGenerated = false; + if (password === undefined) { + password = utils.generateUUID().slice(0, 8); + pwGenerated = true; + } + + const userExists = await user.getUidByUsername(username); + if (userExists) { + return winston.error(`[userCmd/create] A user with username '${username}' already exists`); + } + + const uid = await user.create({ + username, + password, + email, + }); + + winston.info(`[userCmd/create] User '${username}'${password ? '' : ' without a password'} has been created with uid: ${uid}.\ +${pwGenerated ? ` Generated password: ${password}` : ''}`); + } + + async function reset(uid, { password, sendResetEmail }) { + uid = argParsers.intParse(uid, 'uid'); + + if (password === false && sendResetEmail === false) { + return winston.error('[userCmd/reset] At least one option has to be passed (--password or --send-reset-email).'); + } + + const userExists = await user.exists(uid); + if (!userExists) { + return winston.error(`[userCmd/reset] A user with given uid does not exists.`); + } + + let pwGenerated = false; + if (password === '') { + password = utils.generateUUID().slice(0, 8); + pwGenerated = true; + } + + const adminUid = await getAdminUidOrFail(); + + if (password) { + await user.changePassword(adminUid, { + newPassword: password, + uid, + }); + winston.info(`[userCmd/reset] ${password ? 'User password changed.' : ''}${pwGenerated ? ` Generated password: ${password}` : ''}`); + } + + if (sendResetEmail) { + const userEmail = await user.getUserField(uid, 'email'); + if (!userEmail) { + return winston.error('User doesn\'t have an email address to send reset email.'); + } + await setupApp(); + await user.reset.send(userEmail); + winston.info('[userCmd/reset] Password reset email has been sent.'); + } + } + + async function deleteUser(uids, { content }) { + uids = argParsers.intArrayParse(uids, 'uids'); + + const userExists = await user.exists(uids); + if (!userExists || userExists.some(r => r === false)) { + return winston.error(`[userCmd/reset] A user with given uid does not exists.`); + } + + await db.initSessionStore(); + const adminUid = await getAdminUidOrFail(); + + switch (content) { + case 'purge': + await Promise.all(uids.map(uid => user.delete(adminUid, uid))); + winston.info(`[userCmd/delete] User(s) with their content has been deleted.`); + break; + case 'account': + await Promise.all(uids.map(uid => user.deleteAccount(uid))); + winston.info(`[userCmd/delete] User(s) has been deleted, their content left intact.`); + break; + case 'content': + await Promise.all(uids.map(uid => user.deleteContent(adminUid, uid))); + winston.info(`[userCmd/delete] User(s)' content has been deleted.`); + break; + } + } + + async function makeAdmin(uids) { + uids = argParsers.intArrayParse(uids, 'uids'); + await Promise.all(uids.map(uid => groups.join('administrators', uid))); + + winston.info('[userCmd/make/admin] User(s) added as administrators.'); + } + + async function makeGlobalMod(uids) { + uids = argParsers.intArrayParse(uids, 'uids'); + await Promise.all(uids.map(uid => groups.join('Global Moderators', uid))); + + winston.info('[userCmd/make/globalMod] User(s) added as global moderators.'); + } + + async function makeMod(uids, { cid: cids }) { + uids = argParsers.intArrayParse(uids, 'uids'); + cids = argParsers.intArrayParse(cids, 'cids'); + + const categoryPrivList = await privileges.categories.getPrivilegeList(); + await privHelpers.giveOrRescind(groups.join, categoryPrivList, cids, uids); + + winston.info('[userCmd/make/mod] User(s) added as moderators to given categories.'); + } + + async function makeRegular(uids) { + uids = argParsers.intArrayParse(uids, 'uids'); + + await Promise.all(uids.map(uid => groups.leave(['administrators', 'Global Moderators'], uid))); + + const categoryPrivList = await privileges.categories.getPrivilegeList(); + const cids = await db.getSortedSetRevRange('categories:cid', 0, -1); + await privHelpers.giveOrRescind(groups.leave, categoryPrivList, cids, uids); + + winston.info('[userCmd/make/regular] User(s) made regular/non-privileged.'); + } + + return { + info, + create, + reset, + deleteUser, + makeAdmin, + makeGlobalMod, + makeMod, + makeRegular, + }; +}